Compare commits

..

36 Commits

Author SHA1 Message Date
Viktor Liu
ffd696d75f Preserve pre-existing ipsets and roll back partial route ACL installs 2026-08-12 17:41:08 +02:00
Viktor Liu
360e2d4ce1 Fall back from ipset at the point of failure and cover route ACLs 2026-08-12 17:14:34 +02:00
Viktor Liu
db9fcf39ef [client] Gate IPv6 forwarding on overlay v6 and preserve host RA acceptance (#6221) 2026-08-12 16:07:00 +02:00
Lamera
52faa202b2 [client] fall back to per-IP ACL rules when ipset is unavailable (#6332) 2026-08-12 14:37:48 +02:00
Viktor Liu
f5ce0bc65a [client] Fix macOS DNS panic on malformed scutil output (#7180) 2026-08-12 13:25:12 +02:00
Maycon Santos
77e5ac776b [infrastructure] Let a suite outside this repo use the e2e harness (#7176)
e2e/harness documents itself as feature-agnostic, but three details
assumed the caller lives in this repo, so the terraform provider's
acceptance suite would otherwise carry a second harness for the same
product.

repoRoot took the first module root above the working directory as the
Docker build context, which from another module is the caller's own
root, with no combined/Dockerfile.multistage in it. It now requires that
ancestor to be this module, and otherwise asks the go tool for the
source: for a dependent, the extracted directory of the version it pins,
so the server matches the client library it was compiled against. That
lookup uses -mod=readonly, since automatic vendor mode otherwise reports
an empty Dir.

Geolocation was disabled unconditionally. Agent-network ingest does not
use it, but location-based posture checks need the database, and a rule
management cannot evaluate fails rather than passing.
StartClient pinned one network alias and set no hostname, so a second
agent could not start and a peer's name was arbitrary. Management
records that hostname, making it the peer's name in the API.
The client entrypoint is copied with an explicit mode: git tracks it
100755, but the module cache extracts 0444, so a dependent's build
produced a container exiting with "permission denied".

Adds CombinedOption, WithGeolocation, WithServerEnv, ClientOption and
WithClientName.
2026-08-12 11:19:25 +02:00
Maycon Santos
12546e231c [client] adjust gtk3 version release job (#7163)
- Align default names and reuse same environment variables

- With the uploads now targeting the same stable/yum paths as the GTK4
packages, two packages named netbird-ui with the same version and arch
would collide in the repo indexes. Give the GTK3 variant its own
package name and mark the two as conflicting alternatives.

---------

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
2026-08-12 10:34:34 +02:00
Viktor Liu
052cf5a748 [client] Derive Windows SSH privilege checks from the token and group membership (#6966) 2026-08-11 18:16:37 +02:00
Viktor Liu
95a458801c [doc] Point translation contributions at Crowdin (#7161) 2026-08-11 16:02:09 +02:00
Viktor Liu
14f9f8ce22 Fix Crowdin export paths and align import and export with repo bundle state (#7162) 2026-08-11 16:01:33 +02:00
Maycon Santos
f805c149d9 [management] Record reverse proxy usage for activity accounting (#7116)
People who only ever reach private services through the reverse proxy were
invisible to activity accounting. Active users are counted from user.LastLogin
or from the LastSeen of a peer they own, and neither column was written on the
proxy paths — so a person signing in via SSO to a proxied service, or a peer
serving one over the mesh, never showed up in the 24 hour numbers.
Both writes now happen where the proxy already authenticates:
- GenerateSessionToken stamps LastLogin after the session token is signed,
  the same column and the same way the dashboard and device login paths do.
- ValidateTunnelPeer stamps the calling peer's LastSeen, the column its owner
  activates through.
The policy lives in a new reverseproxy/activity manager rather than in the gRPC
service, matching the module layout the other reverse proxy domains use. It
skips what can never count — service users, embedded proxy peers and WASM
clients — and throttles peer writes to once an hour, well inside the window
accounting asks about and far above the proxy's five minute tunnel cache.
The peer write is a single indexed UPDATE that touches only
peer_status_last_seen. Connected and SessionStartedAt are left alone so the
session-ownership fencing MarkPeerConnectedIfNewerSession relies on is never
disturbed, and the timestamp comes from the database clock rather than the
caller, for the same reason the other status writers take it from there. The
caller's cutoff travels into the statement's WHERE, so concurrent requests for
one peer collapse into a single write instead of each acting on its own stale
read, and a peer that was never seen — NULL last seen, since Status is an
embedded pointer — still records its first activity.
Nothing outside the reverse proxy changes behaviour: the only addition
elsewhere is the RefreshPeerLastSeen store method the manager calls.
2026-08-11 15:54:39 +02:00
Viktor Liu
99048e2bf2 [infrastructure] Add Crowdin configuration for UI translation sync (#7155) 2026-08-11 14:24:39 +02:00
Brad Ison
27b2d3f351 [management] Add a proxy-connect authorizer seam (#7136)
At proxy connect time, the declared cluster address is validated for
shape and checked for availability (`IsClusterAddressAvailable`), and
from then on the declaration is what routes the cluster's mappings to
the connection. Deployments that embed management through the
integrations seam may need a policy on that claim — deciding which
credential is allowed to declare which address.

This adds an optional `ProxyConnectAuthorizer` hook on
`ProxyServiceServer`, following the pattern of the existing `Set*` seams
(`SetServiceManager`, `SetAgentNetworkSynthesizer`,
`SetAgentNetworkLimitsService`, `SetProxyController`):

- A nil-able interface field plus `SetProxyConnectAuthorizer`, guarded
by the existing mutex.
- One call at the end of `validateProxyConnect`, so both
`GetMappingUpdate` and `SyncMappings` are covered by a single site.
- **Nothing installs it by default** — with the hook unset (always, in
this repo), behavior is byte-for-byte unchanged, which the tests pin.

Design details:

- The authorizer runs **last** — after input validation and the
availability check — and **outside** the account-scoped branch, so
management-wide tokens and token-less connects are also presented to it
rather than bypassing policy.
- The authorizer receives the presented `*types.ProxyAccessToken` (nil
when none), the proxy ID, and the declared address. Everything it needs
is already in the request/context; no proto or schema change.
- A plain error from the authorizer surfaces as `PermissionDenied`,
keeping an authorization rejection distinguishable from the
`AlreadyExists` used for address conflicts in proxy logs. A status error
passes through unchanged so implementations can pick their own code.
2026-08-10 19:50:00 +02:00
Brad Ison
ebfdf7d7b8 [management] Rework Agent Network endpoint identity and settings bootstrap (#7085)
Store the per-account gateway endpoint as {domain, proxy_address} with a
global unique index on the full hostname; dedicated = (domain ==
proxy_address). Bootstrap becomes an explicit POST carrying exactly one
of proxy_address (server allocates an adjective-noun label beneath it)
or endpoint (claimed verbatim, address-first); provider create loses its
bootstrap side effect. PUT is a full replace with every field required —
the immutable identity fields must be echoed unchanged and a mismatch is
rejected with 422. A guarded DELETE releases the endpoint: refused with
412 while providers exist or a proxy is actively serving the endpoint
hostname (matched case-insensitively); re-creating bootstraps fresh. A
self-addressed pin excludes its address from the account's cluster allow
list, and the live mapping update path now addresses the serving proxy
from the synthesized service. Existing rows are migrated on all three
store engines.
2026-08-10 19:06:55 +02:00
Viktor Liu
e8671a811d [client, relay] Migrate relay QUIC tracer to qlog and bump quic-go to 0.59.1 (#7124) 2026-08-10 17:41:31 +02:00
Zoltan Papp
1ca26d8faa [client] Bump the netbirdio/wails fork to drop the native WebView2 (#7128)
## Describe your changes

The native_webview2loader build tag embedded Microsoft's
WebView2Loader.dll via //go:embed. Those DLLs are gitignored in the fork
and go mod vendor resolves embed patterns regardless of build
constraints, so vendoring the module failed on the missing files when
packaging for openSUSE.

The fork now removes that branch along with the go-winloader dependency,
which drops out of the module graph here. The default GoWebView2Loader
path is unaffected.



## Issue ticket number and link

<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See

https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
  * Updated the application framework revision.
  * Removed an unused dependency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-10 16:41:50 +02:00
Viktor Liu
f9abe2727f [client] Do not misroute WireGuard packets to the STUN handler (#7059) 2026-08-10 13:41:09 +02:00
Zoltan Papp
664a3d026c [client] Fix credentials for the gtk3 package uploads (#7125) 2026-08-10 12:58:50 +02:00
Zoltan Papp
d2c961f67c [client] Declare the xdg-utils dependency for the netbird-ui packages (#7126)
## Describe your changes

The UI shells out to xdg-open to launch the external browser for the SSO
verification page, which the embedded webview cannot open inline, and to
reveal the debug bundle in the file manager.

client/ui/build/linux/nfpm/nfpm.yaml lists xdg-utils for every package
format, but the released packages are built from the goreleaser configs,
where it was missing: the GTK and WebKitGTK dependencies carried over
and xdg-utils did not. Add it to all four nfpm dependency lists.

## Issue ticket number and link

<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See

https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Added required desktop integration support to Debian and RPM packages.
* Ensured GTK3 packages include the same runtime support for opening
links and files through the system.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-10 11:28:15 +02:00
Viktor Liu
5584f8ef0a [client] Add strict anonymization level and MAC anonymization to debug bundles (#7102) 2026-08-10 11:27:20 +02:00
Maycon Santos
f65f7b347e [management] Deny reverse proxy access to pending and blocked users (#7105)
A user in the Pending Approval state could complete SSO and reach any
SSO-protected reverse proxy service distributed to a group they belong
to, including the All Users group. The reverse proxy authorization path
checked the session token signature, that the user exists, that the
user's account matches the service's account, and group membership —
never the user's account status. The REST API (`permissions/manager.go`)
and peer registration both gate on that state, but the proxy gRPC
service does not go through the permissions manager, so neither gate
applied. A pending user is persisted as blocked and pending approval, so
blocked users reached those services the same way.

`ValidateSession` now denies on account status, reporting
`pending_approval` or `user_blocked` so the proxy access log and the
denied page carry the cause rather than a generic refusal.
`GenerateSessionToken` refuses to mint a token for such a user at all,
so the browser never receives a session cookie and the OIDC callback can
tell the user why instead of showing "Service configuration error".
`ValidateUserGroupAccess` and `ValidateTunnelPeer` close the same gap;
for the tunnel path this covers a user blocked after their peer was
registered, since peer group membership alone kept mesh-origin access
open.

A single helper produces both the denied reason for the RPC responses
and the sentinel error for the error-returning callers, so the four
entry points cannot drift apart. A user the store cannot resolve is
denied rather than passed through.

One thing deliberately left out: session cookies are validated locally
by the proxy against the service public key with no management
round-trip, so a cookie issued before a user is blocked stays valid
until it expires (24h by default). That is a revocation-propagation
problem rather than this authorization gap, and every option for it
(per-request validation with a cache, short-lived tokens with refresh,
push-based revocation) changes the proxy hot path or the
proxy/management protocol. Worth its own ticket.
2026-08-08 20:48:34 +09:00
Maycon Santos
179e8f6e13 [infrastructure] add grafana dashboard for licensed management (#7095) 2026-08-08 15:04:06 +09:00
Pascal Fischer
2ee21d2b5c [management] Affected peers for user updates (#7099) 2026-08-07 18:07:53 +02:00
Riccardo Manfrin
eb619fc7e3 [client] disambiguate the connection_type metric tag (#7043)
## Describe your changes

`recordConnectionMetrics` mapped only `conntype.Relay` to `relay` and
let a `default` branch
record everything else as `ice`. That silently included `ICETurn` — an
ICE connection through
a TURN server, which
[`conn.isRelayed`](https://github.com/netbirdio/netbird/blob/main/client/internal/peer/conn.go#L788-L795)
itself counts as relayed — and `None`, the transient state set when the
relay drops

([conn.go:632](https://github.com/netbirdio/netbird/blob/main/client/internal/peer/conn.go#L632))
or the peer state is reset
([conn.go:757](https://github.com/netbirdio/netbird/blob/main/client/internal/peer/conn.go#L757)).
Both were reported as direct peer-to-peer, so the `ice` share overstated
direct connections on
every platform.

The mapping now lists every priority explicitly and emits `ice_p2p`,
`ice_turn`, `relay` or
`unknown`. The new values deliberately do not reuse `ice` to avoid
ambuguity.


## Issue ticket number and link

No public issue. Found while reviewing the first production sample of
client metrics: 38% of iOS
connection events were tagged `ice` on a platform that forces relay by
default, which traced back
to the `default` branch at
[client/internal/peer/conn.go#L963-L968](https://github.com/netbirdio/netbird/blob/main/client/internal/peer/conn.go#L963-L968).

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

Internal metrics documentation only, in
`client/internal/metrics/infra/README.md`: the four
`connection_type` values with their derivation, and a note that pre-fix
`ice` samples are not
comparable with `ice_p2p`. No public API, CLI or configuration change,
so no netbirdio/docs PR.

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

N/A

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
* Connection metrics now distinguish direct peer-to-peer, TURN-assisted,
relay, and unknown connection types.
  * Metrics include clearer connection and peer identification details.

* **Documentation**
* Updated connection timing metric values, traffic semantics, priority
behavior, and historical data guidance.

* **Bug Fixes**
* Unset or unrecognized connection priorities are no longer incorrectly
classified as peer-to-peer.
* Unknown-transport metrics are skipped to prevent misleading connection
data.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-07 17:11:05 +02:00
Nicolas Frati
8632a0d215 [infrastructure] Detect community GHCR images during enterprise migration (#7101)
The migration wizard detected the community deployment only by the
Docker Hub image prefix (netbirdio/netbird-server), so deployments
installed from the ghcr.io mirror failed with "Could not find a service
running netbirdio/netbird-server*".

This broadens the server and dashboard detection to also accept
ghcr.io/netbirdio/... images. The regexes are anchored at the tag/digest
separator so Enterprise images (netbird-server-cloud, dashboard-cloud)
are still rejected, an already-migrated deployment must not be detected
as a community one. Error messages updated to mention both forms.
2026-08-07 17:04:58 +02:00
Riccardo Manfrin
f63fd21e0c [client] peer: re-arm the WireGuard watcher after a lazy wake (#7091)
## Describe your changes

The Conn struct is reused across lazy-connection deactivate/activate.
Close
cancels the WireGuard watcher (via wgWatcherCancel, and ctxCancel also
tears
down its context) but left conn.wgWatcher pointing at the stopped
instance.
enableWgWatcherIfNeeded skips while conn.wgWatcher is non-nil, so the
next Open
never started a fresh watcher: once a lazy connection had idled and
woken, the
peer ran with no watcher at all — no WireGuard handshake-timeout
detection and
none of the escalation that depends on it.

Clear conn.wgWatcher and conn.wgWatcherCancel in Close so the next Open
re-arms
a fresh watcher.

## Issue ticket number and link

<!--
Required for anything that changes behavior. Link the issue (or the
validated
discussion it came from) that the NetBird team already agreed on. See

https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->

## Stack

<!-- branch-stack -->

### Checklist
- [X] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] I ran and tested this change locally — I did not rely on CI to
find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in
one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird
team agreed on beforehand. Changes to the public API, gRPC protocols,
functionality behavior, CLI / service flags, or new features always need
that agreement first. See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [X] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved connection cleanup by fully releasing WireGuard watcher
resources when a connection closes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-07 17:01:08 +02:00
Pascal Fischer
524b8b9718 [management] prewarm a posture check cache on network map generation (#7093) 2026-08-07 15:03:40 +02:00
Zoltan Papp
2ce6323602 [client] Update the wails fork reference to the integration branch head (#7087) 2026-08-07 09:57:52 +02:00
Viktor Liu
9a05a1c698 [client] Reword the firewalld package comment (#7081) 2026-08-06 20:36:13 +02:00
Viktor Liu
5dd914782a [client] Stop the macOS UI on pkg upgrade (#7079) 2026-08-06 16:50:17 +02:00
Ben
8c19b7a30a [infrastructure] Generate a session cookie encryption key on fresh self-hosted installs (#7056)
The community and enterprise bootstrap scripts now generate a dedicated
`server.auth.sessionCookieEncryptionKey` from 32 random bytes for fresh
installations.
The key is Base64-encoded, persisted independently from the datastore
encryption key, and reused from the generated configuration after
restarts.

The community script now also creates `config.yaml` with mode `0600`
before writing it, matching the existing enterprise behavior.

The server already supports the session cookie encryption key, but the
bootstrap scripts left it unset.
This adds defense-in-depth for newly generated deployments while
preserving the existing server-side nonce validation.
Because this only changes newly generated configuration, existing
installations and sessions are unchanged.

A focused regression test checks key presence, decoded length,
separation from the datastore key, YAML placement, and file mode for
both scripts.
It is included in the infrastructure workflow.
2026-08-06 13:55:43 +02:00
Zoltan Papp
1e2a7aa571 [client] Add a UI setting to stay connected after quitting (#7078)
Quitting the GUI from the tray always sent a Down RPC, dropping the VPN
connection with it. Some users want the connection to survive the UI.
2026-08-06 12:56:27 +02:00
Zoltan Papp
98f8a24108 [client] Ship a legacy GTK3 UI package for distros without WebKitGTK 6.0 (#7040)
Ship a legacy GTK3 UI package for distros without WebKitGTK 6.0
2026-08-06 12:13:20 +02:00
Viktor Liu
b2ae98f0fd [misc] Expand agent guidelines with security, type-safety and lifecycle conventions (#7076) 2026-08-06 11:38:24 +02:00
Bethuel Mmbaga
40ac64bb4e [misc] Move enterprise setup to Traefik and harden migration (#7042) 2026-08-05 19:38:02 +03:00
Riccardo Manfrin
19a6cedfff [relay] randomize the relay reconnect backoff (#7067)
## Describe your changes

The relay client's reconnect backoff was constructed without a
`RandomizationFactor`
([guard.go:156-165](https://github.com/netbirdio/netbird/blob/main/shared/relay/client/guard.go#L156-L165)),
so it kept the zero value: every client that lost the same relay server
retried on the identical 2/4/8/16/32/60, potentially in waves.

It was the only exponential backoff in the codebase without a
randomization factor.

Use `backoff.DefaultRandomizationFactor`, as random factor.

## Issue ticket number and link

No public issue. Found while reviewing the relay reconnect path for the
client-metrics review:
22k relay reconnection events in 24h, and the shared transport's own
retry schedule was
identical across all clients.

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

Internal retry-timing change with no user-visible surface: no CLI flag,
configuration option
or API field is added or altered, and the mean reconnect delay is
unchanged.

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

N/A


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved reconnect timing to distribute repeated connection attempts
more evenly and reduce synchronized retry spikes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-05 17:01:21 +02:00
204 changed files with 20361 additions and 6214 deletions

View File

@@ -475,6 +475,132 @@ jobs:
path: dist/
retention-days: 3
release_ui_gtk3:
# Legacy GTK3/WebKit2GTK 4.1 UI build for distros without WebKitGTK 6.0
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). Runs on ubuntu-22.04 so
# the binary links against the oldest supported glibc.
runs-on: ubuntu-22.04
outputs:
release_ui_gtk3_artifact_url: ${{ steps.upload_release_ui_gtk3.outputs.artifact-url }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
persist-credentials: false
- name: Parse semver string
id: semver_parser
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- name: Set snapshot flag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: |
echo "flags=--snapshot" >> $GITHUB_ENV
- name: Set build vars
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
echo "x-${{ github.repository }}"
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
else
echo "x-${{ github.repository }}"
echo "x-${{ steps.semver_parser.outputs.prerelease }}"
fi
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
# Restore-only from the release_ui cache written by trusted runs; the
# module cache is identical (same go.sum) and stale build-cache
# entries just miss.
uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-ui-go-releaser-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-ui-go-releaser-
- name: Install modules
run: go mod tidy
- name: check git status
run: git --no-pager diff --exit-code
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Set up pnpm
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
with:
version: 11
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev
- name: Decode GPG signing key
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
env:
GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }}
run: |
echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
- name: Install wails3 CLI
# Version derived from go.mod so the binding generator always matches
# the wails runtime the binary links against.
# -tags gtk3: the CLI links the wails runtime's cgo packages, and the
# default tags request gtk4/webkitgtk-6.0 pkg-config entries that do
# not exist on ubuntu-22.04.
run: |
WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3)
go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui_gtk3.yaml --clean ${{ env.flags }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
NFPM_NETBIRD_UI_RPM_GTK3_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
- name: Verify RPM signatures
run: |
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
dnf install -y -q rpm-sign curl >/dev/null 2>&1
curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key
rpm --import /tmp/rpm-pub.key
echo "=== Verifying RPM signatures ==="
for rpm_file in /dist/*.rpm; do
[ -f "$rpm_file" ] || continue
echo "--- $(basename $rpm_file) ---"
rpm -K "$rpm_file"
done
'
- name: Clean up GPG key
if: always()
run: rm -f /tmp/gpg-rpm-signing-key.asc
- name: upload non tags for debug purposes
id: upload_release_ui_gtk3
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release-ui-gtk3
path: dist/
retention-days: 3
release_ui_darwin:
runs-on: macos-latest
outputs:
@@ -688,7 +814,7 @@ jobs:
comment_release_artifacts:
name: Comment release artifacts
runs-on: ubuntu-latest
needs: [release, release_ui, release_ui_darwin]
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin]
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
permissions:
contents: read
@@ -700,12 +826,14 @@ jobs:
env:
RELEASE_RESULT: ${{ needs.release.result }}
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
RELEASE_UI_GTK3_RESULT: ${{ needs.release_ui_gtk3.result }}
RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }}
RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }}
LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }}
WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }}
MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }}
RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }}
RELEASE_UI_GTK3_ARTIFACT_URL: ${{ needs.release_ui_gtk3.outputs.release_ui_gtk3_artifact_url }}
RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }}
GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }}
with:
@@ -728,6 +856,7 @@ jobs:
['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT],
['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT],
['UI GTK3 artifacts', process.env.RELEASE_UI_GTK3_ARTIFACT_URL, process.env.RELEASE_UI_GTK3_RESULT],
['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT],
];
@@ -784,7 +913,7 @@ jobs:
trigger_signer:
runs-on: ubuntu-latest
needs: [release, release_ui, release_ui_darwin, test_windows_installer]
needs: [release, release_ui, release_ui_gtk3, release_ui_darwin, test_windows_installer]
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Trigger binaries sign pipelines

View File

@@ -257,6 +257,15 @@ jobs:
with:
persist-credentials: false
- name: Verify fresh-install session cookie key hardening
run: |
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
grep -Fxq ' sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"' infrastructure_files/getting-started.sh
grep -Fxq ' install -m 600 /dev/null config.yaml' infrastructure_files/getting-started.sh
grep -Fxq ' openssl rand -base64 32' infrastructure_files/getting-started-enterprise.sh
grep -Fxq ' NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)' infrastructure_files/getting-started-enterprise.sh
grep -Fxq ' sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"' infrastructure_files/getting-started-enterprise.sh
- name: Verify Dex retirement notice
run: |
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then

View File

@@ -92,15 +92,11 @@ 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.
@@ -120,15 +116,11 @@ 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:

136
.goreleaser_ui_gtk3.yaml Normal file
View File

@@ -0,0 +1,136 @@
version: 2
env:
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
project_name: netbird-ui
before:
hooks:
# Bindings are gitignored; regenerate before the frontend build so
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
# build without them).
# -f '-tags gtk3': the generator type-checks client/ui, whose cgo imports
# would otherwise resolve gtk4/webkitgtk-6.0 pkg-config entries that do
# not exist on ubuntu-22.04.
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts -f "-tags gtk3"'
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
builds:
# Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). The gtk3 tag flips the
# Wails Linux backend to the GTK3 stack and swaps our GTK4-only XEmbed
# tray host for the pure-Go stub (client/ui/xembed_host_gtk3_linux.go).
# Must be built on the oldest supported glibc (ubuntu-22.04 runner).
- id: netbird-ui-gtk3
dir: client/ui
binary: netbird-ui
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- production
- gtk3
archives:
- id: linux-gtk3-arch
name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui-gtk3
nfpms:
# Mutually-exclusive alternative to the GTK4 netbird-ui package -- both
# ship the same /usr/bin/netbird-ui from the shared stable/yum repos, so
# this one carries its own name and conflicts with the GTK4 package.
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_deb_gtk3
package_name: netbird-ui-gtk3
file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui-gtk3
formats:
- deb
scripts:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
conflicts:
- netbird-ui
replaces:
- netbird-ui
dependencies:
- netbird (>= 0.75.0)
- libgtk-3-0
- libwebkit2gtk-4.1-0
- xdg-utils
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_rpm_gtk3
package_name: netbird-ui-gtk3
file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui-gtk3
formats:
- rpm
scripts:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
# No `replaces` here: nfpm maps it to rpm Obsoletes, which would make
# dnf swap installed GTK4 netbird-ui packages for this one on upgrade.
conflicts:
- netbird-ui
dependencies:
- netbird >= 0.75.0
- (gtk3 or libgtk-3-0)
- (webkit2gtk4.1 or libwebkit2gtk-4_1-0)
- xdg-utils
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
# The GTK4 UI job shares project_name, so the default checksum file name would
# collide with it on the shared GitHub release.
checksum:
name_template: "{{ .ProjectName }}_gtk3_checksums.txt"
changelog:
disable: true
uploads:
- name: debian
skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_deb_gtk3
mode: archive
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
username: dev@wiretrustee.com
method: PUT
- name: yum
skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_rpm_gtk3
mode: archive
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
username: dev@wiretrustee.com
method: PUT

317
AGENTS.md
View File

@@ -14,20 +14,22 @@ in this file, not duplicated there.
## Contents
- [NetBird Agent Guidelines](#netbird-agent-guidelines)
- [Contents](#contents)
- [STOP and ask the user before](#stop-and-ask-the-user-before)
- [Quick reference](#quick-reference)
- [Structure](#structure)
- [Where to look](#where-to-look)
- [Repo-wide principles](#repo-wide-principles)
- [Error handling](#error-handling)
- [Comments](#comments)
- [Testing](#testing)
- [Pitfalls](#pitfalls)
- [Commits, PRs, releases](#commits-prs-releases)
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
- [Discussion and support](#discussion-and-support)
- [STOP and ask the user before](#stop-and-ask-the-user-before)
- [Quick reference](#quick-reference)
- [Structure](#structure)
- [Where to look](#where-to-look)
- [Security](#security)
- [Agent conventions](#agent-conventions)
- [Repo-wide principles](#repo-wide-principles)
- [Type safety](#type-safety)
- [Concurrency and lifecycle](#concurrency-and-lifecycle)
- [Error handling](#error-handling)
- [Comments](#comments)
- [Testing](#testing)
- [Pitfalls](#pitfalls)
- [Commits, PRs, releases](#commits-prs-releases)
- [After you push: CI and review bots](#after-you-push-ci-and-review-bots)
- [Discussion and support](#discussion-and-support)
## STOP and ask the user before
@@ -157,11 +159,125 @@ netbird/
| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` |
| End-to-end tests | `e2e/` |
## Security
### Never fail open
When a security check — access control, an IP restriction, an auth decision —
hits an error such as an unparseable value, an unavailable lookup, or a state it
does not recognize, it must **deny**. Never skip the check or allow the request
through because the check itself failed, and make the `default` and unknown cases
of a security-related `switch` deny rather than fall through.
### Daemon RPC input is untrusted
The agent runs as root (LocalSystem on Windows), so a daemon RPC crosses a
privilege boundary: treat every field as untrusted input rather than as something
the UI or CLI validated on the way in.
When you add or change an RPC, ask what the handler does with caller input while
running as root. If the answer touches a filesystem path, a URL or host, or a
privileged state change, it needs a gate **in the handler** — a check in the client
that normally calls it is not a check at all.
- **A caller-supplied path the daemon opens.** Never `os.Open` it as root.
Constrain it, then open it *as the caller* with `ipcauth.OpenOwnedFile`, which
opens `O_NOFOLLOW`, requires a regular file, and refuses a file the caller does
not own — so a symlink or hardlink aimed at a root-only file is rejected.
- **A caller-supplied URL or host the daemon fetches.** Restrict the scheme and
allow only known hosts for unprivileged callers. Prefer a lexical host
allowlist plus TLS verification over "resolve the host, then reject private
IPs": the resolve-then-trust pattern has a DNS-rebinding race (public IP at
check time, attacker IP at connect time), while a name allowlist has no IP
check to race. Never accept `http://` where `https://` is expected.
- **A privileged state change** (SSH root login, management URL, deregistration)
gates on the caller identity from `ipcauth.CallerIdentity(ctx)`.
Caller identity comes from the kernel — `SO_PEERCRED`, `LOCAL_PEERCRED`, or the
named-pipe client token — and never from an RPC field. When
`ipcauth.CallerIdentity` reports that it could not determine an identity, **deny**;
do not fall back to treating the caller as the transport peer.
## Agent conventions
### Three networking modes
Where packets actually flow depends on the mode the agent is running in. The
three are not interchangeable, so establish which one a change applies to — and
what it should do in the other two — before you write it.
- **kernel mode** (Linux only): in-kernel WireGuard®. The kernel handles both
peer-to-peer and routed traffic, and ACLs are iptables or nftables rules. The
client programs kernel facilities but never sees the traffic itself.
- **userspace mode** (wireguard-go with a TUN): wireguard-go runs in-process. The
kernel handles peer-to-peer traffic once it leaves the TUN, while routed traffic
— exit nodes and network routes — goes through the userspace forwarder, which
terminates the connection and re-establishes it over OS sockets. Used on
platforms without kernel WireGuard® or when the user opts out.
- **netstack mode**: wireguard-go in-process with no TUN and no kernel
networking. The forwarder does all routing by stitching userspace sockets, and
listeners such as the embedded SSH and DNS servers bind on a gVisor netstack.
Used where the process cannot create a TUN device, such as the embedded client
(`client/embed/`) and the WASM build.
### The overlay interface is not "WireGuard"
Do not put "WireGuard" in identifiers or comments unless the code is genuinely
coupled to WireGuard® specifically — a wireguard-go call, a handshake field, a
kernel WireGuard® netlink attribute. For the interface, the host, peers, or
traffic in general, say "the NetBird interface", "the interface", or "the overlay".
Most firewall, routing, and DNS code is transport-agnostic, so a WireGuard®
reference there is simply inaccurate and rots as the transports change.
### IPv6 is a soft feature
The IPv6 overlay is opt-in dual-stack, and capability can change at runtime. Treat
it as soft rather than a requirement:
- Gate local v6 paths on the interface accessor (`wgIface.Address().HasIPv6()`),
not on raw state fields, and skip the v6 path when the host has no v6 rather
than returning an error.
- Treat an empty or unparseable peer v6 address as "no v6 for that peer" and skip
it, keeping the v4 path working.
- Never let a missing v6 break v4. Fail-closed is for security checks; a
capability mismatch skips the v6 work and carries on.
### Environment variables
Name the variable in a constant and parse booleans with `strconv.ParseBool` rather
than comparing strings inline, so an unexpected value is logged instead of
silently meaning false:
```go
const EnvDisableFeature = "NB_DISABLE_FEATURE"
func isDisabledByEnv() bool {
val := os.Getenv(EnvDisableFeature)
if val == "" {
return false
}
disabled, err := strconv.ParseBool(val)
if err != nil {
log.Warnf("failed to parse %s: %v", EnvDisableFeature, err)
return false
}
return disabled
}
```
### Validating against protocol specs
When a change depends on what a protocol actually mandates, read the specification
text from the [IETF datatracker](https://datatracker.ietf.org/) rather than a
summary, and check that you have the current RFC — the widely cited one for a
protocol is often superseded. Cite the section, not just the document, so a
reviewer can jump straight to the rule.
## Repo-wide principles
1. **Run `go fmt` on every modified Go file.** Formatting is not optional.
2. **Zero unaddressed diagnostics.** Fix IDE and linter warnings on code you
touch, and delete imports, helpers, and parameters your refactor orphaned.
2. **Zero unaddressed linter warnings.** Fix what `golangci-lint` reports on code
you touch, and delete imports, helpers, and parameters your refactor orphaned.
Exception: unused parameters in shared code may be consumed by builds outside
this repository — do not remove them, ask instead.
3. **Function comments are mandatory for exported functions**, written as full
@@ -175,9 +291,12 @@ netbird/
7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in
prose, trailing summaries. Defaults, not absolute bans. Applies to code,
comments, commit messages, and PR descriptions alike.
8. **Concurrency: do a two-pass race analysis after every change** that adds
shared state. Guard maps and slices with a mutex, keep critical sections
short, and run `go test -race` on the touched packages.
8. **Concurrency: do a two-pass race analysis after every change** that touches
shared state, including reads of existing maps and slices. Guard them with a
mutex (or an atomic or channel where that fits better), keep critical
sections short, and run `go test -race` on the touched packages. See
[Concurrency and lifecycle](#concurrency-and-lifecycle) for the failure modes
to check for.
9. **Cross-platform builds must keep working.** The agent targets Linux, macOS,
Windows, FreeBSD, Android, and iOS. When you add a platform-specific file,
add the counterpart or a build-tagged fallback for the others.
@@ -185,6 +304,93 @@ netbird/
11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and
keep peer IPs and hostnames out of logs above debug level.
## Type safety
**No bare primitives for domain concepts.** A `string` parameter for an account
ID next to a `string` parameter for a peer ID is two bugs waiting to happen,
because the compiler cannot catch the swap. Declare the type once and use it
throughout, converting only at the boundaries where data enters or leaves —
protobuf, gRPC, HTTP, an external library.
```go
type ServiceID string
type AccountID string
// Internal: typed all the way through
func (r *Router) RemoveRoute(host SNIHost, svcID ServiceID) { ... }
// Proto boundary: convert once, on the way in and on the way out
svcID := ServiceID(mapping.GetId())
req.ServiceId = string(svcID)
```
- **IP addresses are `netip.Addr`**, not `string` and not `net.IP`. Parse at the
boundary and pass the typed value inward.
- **Always `Unmap()`** after parsing an address, after converting from `net.IP`,
and after extracting one from `RemoteAddr()`. This normalizes a v4-mapped v6
address (`::ffff:10.1.2.3`) to plain v4 so IPv4 rules match it. A stored or
compared mapped address silently fails to match those rules.
- **Ports are `uint16`** internally; use `int` only where a library forces it and
convert immediately.
- **Enums are a typed string with constants**, so the valid set is discoverable
and a typo fails to compile.
- **Map keys follow the same rule**, and must be a real type (`type ServiceID
string`) rather than an alias (`type serviceID = string`) — an alias silently
accepts bare strings.
## Concurrency and lifecycle
Beyond the mutex hygiene in the principles above, check for these failure
modes.
- **Never read a struct field inside a goroutine** when another goroutine may nil
or reassign it. Pass the value as a parameter, or capture it into a local before
launching. This matters most when `Stop()` nils a field without waiting for the
goroutine to finish.
```go
go func(ifaceName string) { // good: passed in, cannot be nilled underneath
m.Start(ctx, ifaceName)
}(iface.Name())
```
- **Never wait on a channel while holding a lock the sender needs.** Copy what you
need out from under the lock, release it, then wait.
```go
func (m *Manager) Stop() {
m.mu.Lock()
cancel, done := m.cancel, m.done
m.mu.Unlock()
if cancel != nil {
cancel()
<-done
}
}
```
- **`Stop`/`Close` must be idempotent** — guard on an already-stopped flag or a
nil cancel — and must release the state they guarded. Clear maps and caches;
a cancelled goroutine holding a live map still pins that memory. Note that a
nil map only panics on writes; reads and iteration behave like an empty map,
so where post-close use must be rejected, check the stopped flag explicitly.
- **Publish coupled state only after every fallible step succeeds.** When several
fields form an invariant, build them into locals and assign them to the receiver
at the end. Assigning as you go leaves the object half-initialized when a later
step fails, so a readiness predicate reports ready while a coupled field is nil.
If an earlier step already had an external side effect — a created chain, an
opened handle, an inserted rule — roll it back before returning the error.
- **Clean up what you own on constructor error paths.** Once a constructor has
started something, every later error path must undo it: cancel a goroutine and
wait for it to exit, stop a ticker, close a watcher. The object is never
returned, so its `Close` will never run.
- **A failed `Start` must undo everything it started.** When a component brings up
several subsystems in sequence — connection manager, watchers, routing, DNS,
flow, persisted state — a failure partway through has to tear down the ones
already running, not just close the handle the error came from. Put the
already-started guard *before* that teardown path, so a rejected second `Start`
cannot dismantle the one that is running.
## Error handling
Use single-assignment form when the error is only needed inside the `if`:
@@ -248,6 +454,45 @@ Log the errors you choose not to act on:
- Close errors may be ignored for read-only operations; log them at debug for
writes.
**Do not log and return the same error.** It gets reported twice, from two places,
and the second reader cannot tell whether it happened once or twice. Return it and
let the caller decide. The exception is an API handler that has already written a
response. Internal helpers return errors rather than logging and swallowing them.
**Never return a typed nil as an error.** A nil `*MyError` stored in an `error`
interface is not nil, so `err != nil` is true and callers take the failure path on
success. Return the error only where it is actually set:
```go
if _, err := conn.Write(buf); err != nil { // good
return err
}
return nil
```
**Accumulate with `multierror` when an operation should continue past individual
failures** — teardown, cleanup, or setup where partial success is acceptable.
`client/errors.FormatErrorOrNil` returns nil for an empty accumulator, so callers
still see a plain nil on full success:
```go
func (m *Manager) Cleanup() error {
var merr *multierror.Error
for _, r := range m.resources {
if err := r.Close(); err != nil {
merr = multierror.Append(merr, fmt.Errorf("close %s: %w", r.Name, err))
}
}
return nberrors.FormatErrorOrNil(merr)
}
```
| Scenario | Approach | Why |
| --------------------- | --------------------- | ----------------------------------------- |
| Cleanup / teardown | Accumulate | Clean up as much as possible |
| Setup with rollback | Abort on first error | Partial state is invalid; undo what stuck |
| Setup with partial OK | Accumulate | Degraded operation is still useful |
## Comments
Comment the **why**, never the **what**. Default to no comment, and add one only
@@ -269,10 +514,14 @@ checksum = updateChecksum(checksum, oldPort, newPort)
### Length budget
- **90 characters per line.** Wrap the comment, do not run past it.
- **250 characters per comment**, roughly three wrapped lines. Doc comments on
exported identifiers may exceed it when the API genuinely needs the
explanation; inline comments inside a function body may not.
Neither of these is linter-enforced, so they are conventions the surrounding code
mostly follows rather than hard limits:
- **Around 90 characters per line.** Wrap the comment rather than running well past
it.
- **Roughly 250 characters per comment**, about three wrapped lines. Doc comments
on exported identifiers may exceed it when the API genuinely needs the
explanation; inline comments inside a function body rarely should.
The budget is a smell detector, not a rule to game. Do not compress a needed
explanation into cryptic shorthand to fit — if a block of code needs more than
@@ -329,6 +578,19 @@ up, and the 250-character budget does not apply to them.
otherwise.
- **Message guidance:** optional for `NoError`/`Error`; always give context for
comparison, boolean, and collection assertions.
- **Reproduce a bug before fixing it.** Write the test, watch it fail *for the
reason you expect* — a test that fails for an unrelated reason proves nothing —
then apply the fix and confirm it passes. Add the thin surrounding cases while
you are there.
- **Use `t.Setenv`** rather than `os.Setenv` so the previous value is restored on
cleanup. To test the unset case, call `t.Setenv` first to register the restore,
then `os.Unsetenv`.
- **Prefer `t.Cleanup` over `defer`** in any test with parallel subtests: the
parent function returns, running its `defer`s, while parallel subtests are
still suspended. Sequential subtests finish inside `t.Run`, so `defer` is safe
there, but `t.Cleanup` works in both cases.
- **Explanatory comments in tests are welcome.** Describe the scenario being set
up; the comment budget below does not apply to them.
```go
server, err := StartTestServer()
@@ -380,7 +642,8 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
than replacing it with your own summary: describe the change, link the issue,
tick the checklist honestly (including "ran locally" and "single purpose"),
and complete the documentation section. Do not tick a box you have not
verified, and do not delete rows that do not apply.
verified, and do not delete rows that do not apply — the docs gate in CI reads
that section and fails when it is missing.
- **Keep the PR description short.** Under 1000 words on top of the template's
own text, and usually far less — a few paragraphs. Reviewers read the diff;
@@ -439,6 +702,12 @@ assert.Equal(t, expectedResult, result, "Result should match expected")
on their own. Propose that split to the user rather than opening one large PR
and hoping.
Prefer GitHub's stacked pull requests for such a sequence, rather than
hand-managing base branches: open each PR against the branch below it instead of
`main`, so every PR's diff shows only its own change. Merging a layer retargets
the PRs above it, and branch protections and required checks on the base branch
still apply to each one.
- **User-facing changes need a docs PR** in
[netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR
description.

View File

@@ -112,6 +112,7 @@ 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)
@@ -612,6 +613,17 @@ 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:

View File

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

View File

@@ -2,6 +2,7 @@ package anonymize
import (
"crypto/rand"
"encoding/base64"
"fmt"
"math/big"
"net"
@@ -15,13 +16,88 @@ import (
const anonTLD = ".domain"
// Level selects how much the anonymizer redacts. Levels are ordered: a higher
// level redacts strictly more. On the wire (protos, flags) levels travel as
// their string form.
type Level int
const (
// LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC
// addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are
// preserved so support can reason about the real topology.
LevelDefault Level = iota
// LevelStrict additionally anonymizes internal IP ranges, peer names, and
// WireGuard public keys.
LevelStrict
)
// LevelDefaultString and LevelStrictString are the wire forms of the levels,
// for boundaries that pass levels as strings (flags, protos, mobile bindings).
const (
LevelDefaultString = "default"
LevelStrictString = "strict"
)
// ParseLevel maps s to a Level. Empty means LevelDefault; anything
// unrecognized maps to LevelStrict so an unknown request never yields less
// anonymization than intended.
func ParseLevel(s string) Level {
switch strings.ToLower(s) {
case "", LevelDefaultString:
return LevelDefault
default:
return LevelStrict
}
}
// String returns the wire form of the level: "default" or "strict".
func (l Level) String() string {
if l >= LevelStrict {
return LevelStrictString
}
return LevelDefaultString
}
// protectedDomains are NetBird-operated suffixes that stay recognizable in an
// anonymized bundle. At LevelStrict the labels in front of them (the peer
// name) are still replaced, except under netbird.io, which only hosts
// NetBird infrastructure (api, signal, flow), never peer names.
var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"}
const infraDomain = "netbird.io"
var (
macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`)
macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`)
wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`)
)
type Anonymizer struct {
ipAnonymizer map[netip.Addr]netip.Addr
domainAnonymizer map[string]string
currentAnonIPv4 netip.Addr
currentAnonIPv6 netip.Addr
startAnonIPv4 netip.Addr
startAnonIPv6 netip.Addr
// domainOrder caches the keys of domainAnonymizer sorted longest-first
// for AnonymizeString; it is rebuilt when the map gains entries.
domainOrder []string
labelAnonymizer map[string]string
labelAnonymized map[string]struct{}
labelCounter uint32
macAnonymizer map[string]string
macCounter uint32
wgKeyAnonymizer map[string]string
wgKeyAnonymized map[string]struct{}
currentAnonIPv4 netip.Addr
currentAnonIPv6 netip.Addr
startAnonIPv4 netip.Addr
startAnonIPv6 netip.Addr
// LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT,
// link-local), replacing them from the dedicated internal pools below so
// a reader can still tell an internal address from a public one.
level Level
currentAnonInternalIPv4 netip.Addr
currentAnonInternalIPv6 netip.Addr
startAnonInternalIPv4 netip.Addr
startAnonInternalIPv6 netip.Addr
domainKeyRegex *regexp.Regexp
}
@@ -32,25 +108,50 @@ func DefaultAddresses() (netip.Addr, netip.Addr) {
return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::")
}
// InternalAddresses returns the pool starts used in strict mode for internal
// ranges. Both are reserved ranges that cannot collide with real addressing:
// 198.18.0.0 (RFC 2544 benchmarking), 2001:db8:1:: (RFC 3849 documentation).
func InternalAddresses() (netip.Addr, netip.Addr) {
return netip.AddrFrom4([4]byte{198, 18, 0, 0}), netip.MustParseAddr("2001:db8:1::")
}
func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer {
internalIPv4, internalIPv6 := InternalAddresses()
return &Anonymizer{
ipAnonymizer: map[netip.Addr]netip.Addr{},
domainAnonymizer: map[string]string{},
labelAnonymizer: map[string]string{},
labelAnonymized: map[string]struct{}{},
macAnonymizer: map[string]string{},
wgKeyAnonymizer: map[string]string{},
wgKeyAnonymized: map[string]struct{}{},
currentAnonIPv4: startIPv4,
currentAnonIPv6: startIPv6,
startAnonIPv4: startIPv4,
startAnonIPv6: startIPv6,
level: LevelDefault,
currentAnonInternalIPv4: internalIPv4,
currentAnonInternalIPv6: internalIPv6,
startAnonInternalIPv4: internalIPv4,
startAnonInternalIPv6: internalIPv6,
domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`),
}
}
// SetLevel selects the anonymization level. The zero value of a new
// Anonymizer is LevelDefault.
func (a *Anonymizer) SetLevel(level Level) {
a.level = level
}
func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
// Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps
// like 192.168.1.1.
ip = ip.Unmap()
if ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
(ip.Is4() && ip.IsPrivate()) ||
ip.IsUnspecified() ||
ip.IsMulticast() ||
isWellKnown(ip) ||
@@ -59,18 +160,100 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr {
return ip
}
if isInternal(ip) && a.level < LevelStrict {
return ip
}
if _, ok := a.ipAnonymizer[ip]; !ok {
if ip.Is4() {
a.ipAnonymizer[ip] = a.currentAnonIPv4
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
} else {
a.ipAnonymizer[ip] = a.currentAnonIPv6
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
}
a.ipAnonymizer[ip] = a.nextAnonIP(ip)
}
return a.ipAnonymizer[ip]
}
func (a *Anonymizer) nextAnonIP(ip netip.Addr) netip.Addr {
// At the strict level, internal addresses (including IPv6 ULA, matched
// by IsPrivate) come from the internal pools so they remain recognizable
// as internal without disclosing the real values.
if a.level >= LevelStrict && (isInternal(ip) || ip.IsPrivate()) {
if ip.Is4() {
anon := a.currentAnonInternalIPv4
a.currentAnonInternalIPv4 = a.currentAnonInternalIPv4.Next()
return anon
}
anon := a.currentAnonInternalIPv6
a.currentAnonInternalIPv6 = a.currentAnonInternalIPv6.Next()
return anon
}
if ip.Is4() {
anon := a.currentAnonIPv4
a.currentAnonIPv4 = a.currentAnonIPv4.Next()
return anon
}
anon := a.currentAnonIPv6
a.currentAnonIPv6 = a.currentAnonIPv6.Next()
return anon
}
// AnonymizeMAC replaces a MAC address with a consistent placeholder from the
// locally administered range starting at 02:00:00:00:00:01, at every
// anonymization level. Broadcast, multicast, all-zero, and already assigned
// placeholder addresses are preserved. The colon and dash spellings of the
// same address share one placeholder; the output keeps the input's separator.
func (a *Anonymizer) AnonymizeMAC(mac string) string {
hw, err := net.ParseMAC(mac)
if err != nil || len(hw) != 6 {
return mac
}
if isWellKnownMAC(hw) || a.isAnonymizedMAC(hw) {
return mac
}
key := hw.String()
anon, ok := a.macAnonymizer[key]
if !ok {
a.macCounter++
anon = fmt.Sprintf("02:00:00:%02x:%02x:%02x", byte(a.macCounter>>16), byte(a.macCounter>>8), byte(a.macCounter))
a.macAnonymizer[key] = anon
}
if strings.Contains(mac, "-") {
anon = strings.ReplaceAll(anon, ":", "-")
}
return anon
}
// isAnonymizedMAC reports whether hw is a placeholder this anonymizer already
// handed out, so a second pass over anonymized output leaves it unchanged.
func (a *Anonymizer) isAnonymizedMAC(hw net.HardwareAddr) bool {
if hw[0] != 0x02 || hw[1] != 0 || hw[2] != 0 {
return false
}
value := uint32(hw[3])<<16 | uint32(hw[4])<<8 | uint32(hw[5])
return value <= a.macCounter
}
// AnonymizeWGKey replaces a WireGuard public key with a consistent random
// placeholder of the same shape. Keys are only anonymized at LevelStrict;
// placeholders already handed out pass through unchanged.
func (a *Anonymizer) AnonymizeWGKey(key string) string {
if a.level < LevelStrict || !looksLikeWGKey(key) {
return key
}
if _, ok := a.wgKeyAnonymized[key]; ok {
return key
}
anon, ok := a.wgKeyAnonymizer[key]
if !ok {
anon = generateAnonymousKey()
a.wgKeyAnonymizer[key] = anon
a.wgKeyAnonymized[anon] = struct{}{}
}
return anon
}
func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
// Convert IP to netip.Addr
ip, ok := netip.AddrFromSlice(addr.IP)
@@ -89,12 +272,12 @@ func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr {
// isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs
func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool {
if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 {
return true
} else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 {
return true
if ip.Is4() {
return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) ||
inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4)
}
return false
return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) ||
inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6)
}
func (a *Anonymizer) AnonymizeIPString(ip string) string {
@@ -118,14 +301,17 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
baseDomain = domain[:len(domain)-1]
}
if strings.HasSuffix(baseDomain, "netbird.io") ||
strings.HasSuffix(baseDomain, "netbird.selfhosted") ||
strings.HasSuffix(baseDomain, "netbird.cloud") ||
strings.HasSuffix(baseDomain, "netbird.stage") ||
strings.HasSuffix(baseDomain, anonTLD) {
if strings.HasSuffix(baseDomain, anonTLD) {
return domain
}
if suffix := protectedSuffix(baseDomain); suffix != "" {
if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
return domain
}
return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot)
}
parts := strings.Split(baseDomain, ".")
if len(parts) < 2 {
return domain
@@ -141,12 +327,53 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
}
result := strings.Replace(baseDomain, baseForLookup, anonymized, 1)
if hasDot {
result += "."
if a.level >= LevelStrict && len(parts) > 2 {
prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup)
result = a.anonymizeLabels(prefix, "host") + "." + anonymized
// The full mapping feeds AnonymizeString so seeded FQDNs are caught
// in log lines as a whole, labels included.
a.domainAnonymizer[baseDomain] = result
}
return withTrailingDot(result, hasDot)
}
// anonymizePeerName replaces the labels in front of a protected suffix with
// numbered peer placeholders, keeping the suffix, and records the full
// mapping for string replacement in logs. The numbering keeps a peer
// recognizable across the whole bundle without disclosing its name.
func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string {
prefix := strings.TrimSuffix(baseDomain, "."+suffix)
result := a.anonymizeLabels(prefix, "peer") + "." + suffix
if result != baseDomain {
a.domainAnonymizer[baseDomain] = result
}
return result
}
// anonymizeLabels replaces each dot-separated label with a consistent
// numbered placeholder ("<placeholder>-<n>"). Wildcard labels and
// placeholders already handed out pass through unchanged.
func (a *Anonymizer) anonymizeLabels(prefix, placeholder string) string {
labels := strings.Split(prefix, ".")
for i, label := range labels {
if label == "*" {
continue
}
if _, ok := a.labelAnonymized[label]; ok {
continue
}
anon, ok := a.labelAnonymizer[label]
if !ok {
a.labelCounter++
anon = fmt.Sprintf("%s-%d", placeholder, a.labelCounter)
a.labelAnonymizer[label] = anon
a.labelAnonymized[anon] = struct{}{}
}
labels[i] = anon
}
return strings.Join(labels, ".")
}
func (a *Anonymizer) AnonymizeURI(uri string) string {
u, err := url.Parse(uri)
if err != nil {
@@ -181,16 +408,70 @@ func (a *Anonymizer) AnonymizeString(str string) string {
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
for domain, anonDomain := range a.domainAnonymizer {
str = strings.ReplaceAll(str, domain, anonDomain)
for _, domain := range a.sortedDomains() {
str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain])
}
str = a.AnonymizeSchemeURI(str)
str = a.AnonymizeDNSLogLine(str)
// MAC handling runs after the IP passes so preserved IPv6 addresses are
// already out of the way; the separator guard skips matches embedded in a
// longer colon- or dash-separated sequence (such as an IPv6 tail).
str = a.anonymizeMACsInString(str, macColonRegex, ':')
str = a.anonymizeMACsInString(str, macDashRegex, '-')
if a.level >= LevelStrict {
str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
}
return str
}
// sortedDomains returns the domain mappings longest-first, so a full-FQDN
// mapping (strict level) is applied before the base-domain mapping it
// contains. The order is rebuilt only when domainAnonymizer has grown.
func (a *Anonymizer) sortedDomains() []string {
if len(a.domainOrder) == len(a.domainAnonymizer) {
return a.domainOrder
}
a.domainOrder = a.domainOrder[:0]
for domain := range a.domainAnonymizer {
a.domainOrder = append(a.domainOrder, domain)
}
slices.SortFunc(a.domainOrder, func(x, y string) int {
if d := len(y) - len(x); d != 0 {
return d
}
return strings.Compare(x, y)
})
return a.domainOrder
}
// anonymizeMACsInString replaces MAC addresses matched by re, skipping
// matches that directly adjoin another sep so a six-group run inside a longer
// separated sequence is left alone.
func (a *Anonymizer) anonymizeMACsInString(str string, re *regexp.Regexp, sep byte) string {
matches := re.FindAllStringIndex(str, -1)
if len(matches) == 0 {
return str
}
var b strings.Builder
last := 0
for _, m := range matches {
if (m[0] > 0 && str[m[0]-1] == sep) || (m[1] < len(str) && str[m[1]] == sep) {
continue
}
b.WriteString(str[last:m[0]])
b.WriteString(a.AnonymizeMAC(str[m[0]:m[1]]))
last = m[1]
}
b.WriteString(str[last:])
return b.String()
}
// AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes.
func (a *Anonymizer) AnonymizeSchemeURI(text string) string {
re := regexp.MustCompile(`(?i)\b(wss?://|rels?://|stuns?:|turns?:|https?://)\S+\b`)
@@ -239,10 +520,79 @@ func isWellKnown(addr netip.Addr) bool {
"128.0.0.0", "8000::", // 2nd split subnet for default routes
}
if slices.Contains(wellKnown, addr.String()) {
return slices.Contains(wellKnown, addr.String())
}
// isInternal reports whether ip identifies a host only within the local
// network: IPv4 private (RFC 1918), CGNAT (RFC 6598), and link-local (v4 and
// v6). These are preserved at the default level so support can reason about
// the real topology, and replaced from the internal pools at the strict
// level. IPv6 ULA is deliberately not internal: its random global ID uniquely
// fingerprints the network, so it is anonymized at every level.
func isInternal(ip netip.Addr) bool {
return (ip.Is4() && ip.IsPrivate()) ||
ip.IsLinkLocalUnicast() ||
isCGNAT(ip)
}
func inPoolRange(ip, start, current netip.Addr) bool {
return ip.Compare(start) >= 0 && ip.Compare(current) <= 0
}
// isWellKnownMAC reports whether hw carries no stable host identity: all-zero
// or a group address (broadcast and multicast).
func isWellKnownMAC(hw net.HardwareAddr) bool {
if hw[0]&1 == 1 {
return true
}
for _, b := range hw {
if b != 0 {
return false
}
}
return true
}
// looksLikeWGKey reports whether s has the shape of a WireGuard key:
// 44 base64 characters decoding to 32 bytes.
func looksLikeWGKey(s string) bool {
if len(s) != 44 || s[43] != '=' {
return false
}
decoded, err := base64.StdEncoding.DecodeString(s)
return err == nil && len(decoded) == 32
}
func generateAnonymousKey() string {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return strings.Repeat("A", 43) + "="
}
return base64.StdEncoding.EncodeToString(buf)
}
// protectedSuffix returns the protected NetBird suffix baseDomain ends with,
// or empty. The match is label-anchored so an unrelated domain that merely
// ends in the same characters is not preserved.
func protectedSuffix(baseDomain string) string {
for _, d := range protectedDomains {
if baseDomain == d || strings.HasSuffix(baseDomain, "."+d) {
return d
}
}
return ""
}
func withTrailingDot(domain string, hasDot bool) string {
if hasDot {
return domain + "."
}
return domain
}
// isCGNAT reports whether addr is in 100.64.0.0/10 (RFC 6598), the range
// NetBird assigns overlay peer addresses from.
func isCGNAT(addr netip.Addr) bool {
cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0})
cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10)

View File

@@ -1,8 +1,11 @@
package anonymize_test
import (
"bytes"
"encoding/base64"
"net/netip"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -44,6 +47,301 @@ func TestAnonymizeIP(t *testing.T) {
}
}
func TestParseLevel(t *testing.T) {
tests := []struct {
input string
expect anonymize.Level
}{
{"", anonymize.LevelDefault},
{"default", anonymize.LevelDefault},
{"DEFAULT", anonymize.LevelDefault},
{"strict", anonymize.LevelStrict},
{"STRICT", anonymize.LevelStrict},
// Unknown values must never yield less anonymization than requested.
{"garbage", anonymize.LevelStrict},
}
for _, tc := range tests {
t.Run("input="+tc.input, func(t *testing.T) {
assert.Equal(t, tc.expect, anonymize.ParseLevel(tc.input), "parsed level should match")
})
}
}
func TestAnonymizeIP_DefaultLevelInternalRanges(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
tests := []struct {
name string
ip string
expect string
}{
{"RFC1918 10/8", "10.1.2.3", "10.1.2.3"},
{"RFC1918 172.16/12", "172.16.5.5", "172.16.5.5"},
{"RFC1918 192.168/16", "192.168.1.1", "192.168.1.1"},
{"CGNAT", "100.64.0.5", "100.64.0.5"},
{"IPv4 link-local", "169.254.1.1", "169.254.1.1"},
{"IPv6 link-local", "fe80::1", "fe80::1"},
// ULA is anonymized even at the default level: its random global ID
// uniquely fingerprints the network, unlike shared RFC 1918 space.
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:ffff::"},
// 4-in-6 addresses classify like their unmapped IPv4 form.
{"4-in-6 RFC1918", "::ffff:192.168.1.1", "192.168.1.1"},
{"4-in-6 CGNAT", "::ffff:100.64.0.5", "100.64.0.5"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
assert.Equal(t, tc.expect, result.String(), "default level should preserve internal ranges except ULA")
})
}
}
func TestAnonymizeIP_StrictLevel(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
// Order matters: internal pool addresses are assigned sequentially.
tests := []struct {
name string
ip string
expect string
}{
{"RFC1918 192.168/16", "192.168.1.1", "198.18.0.0"},
{"Second RFC1918", "192.168.1.2", "198.18.0.1"},
{"Repeated RFC1918", "192.168.1.1", "198.18.0.0"},
{"RFC1918 10/8", "10.1.2.3", "198.18.0.2"},
{"RFC1918 172.16/12", "172.16.5.5", "198.18.0.3"},
{"CGNAT", "100.64.0.5", "198.18.0.4"},
{"IPv4 link-local", "169.254.1.1", "198.18.0.5"},
{"Public IPv4 uses public pool", "1.2.3.4", "198.51.100.0"},
{"IPv6 link-local", "fe80::1", "2001:db8:1::"},
{"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:1::1"},
{"Public IPv6 uses public pool", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"},
{"Loopback IPv4", "127.0.0.1", "127.0.0.1"},
{"Loopback IPv6", "::1", "::1"},
{"Unspecified", "0.0.0.0", "0.0.0.0"},
{"Multicast", "224.0.0.251", "224.0.0.251"},
{"Well known resolver", "8.8.8.8", "8.8.8.8"},
{"Well known split marker", "128.0.0.0", "128.0.0.0"},
{"In internal pool range", "198.18.0.3", "198.18.0.3"},
{"In public pool range", "198.51.100.0", "198.51.100.0"},
{"4-in-6 repeated RFC1918", "::ffff:192.168.1.1", "198.18.0.0"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip))
assert.Equal(t, tc.expect, result.String(), "strict level should replace internal ranges from the internal pools")
})
}
}
func TestAnonymizeString_StrictInternalIPs(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
input := "route 10.20.30.0/24 via 192.168.1.1 dev eth0 src 100.64.0.7"
firstPass := anonymizer.AnonymizeString(input)
secondPass := anonymizer.AnonymizeString(firstPass)
assert.NotContains(t, firstPass, "10.20.30.0", "private network address should be anonymized")
assert.NotContains(t, firstPass, "192.168.1.1", "private gateway should be anonymized")
assert.NotContains(t, firstPass, "100.64.0.7", "CGNAT address should be anonymized")
assert.Contains(t, firstPass, "/24", "prefix length should be preserved")
assert.Equal(t, firstPass, secondPass, "second pass should not further anonymize the string")
}
func TestAnonymizeMAC(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
first := anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f")
assert.Equal(t, "02:00:00:00:00:01", first, "first MAC should get the first placeholder")
assert.Equal(t, first, anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f"), "repeated MAC should map to the same placeholder")
assert.Equal(t, first, anonymizer.AnonymizeMAC("AA:BB:CC:DD:EE:0F"), "case should not affect the mapping")
assert.Equal(t, "02-00-00-00-00-01", anonymizer.AnonymizeMAC("AA-BB-CC-DD-EE-0F"), "dash form should keep its separator but share the mapping")
second := anonymizer.AnonymizeMAC("10:22:33:44:55:66")
assert.Equal(t, "02:00:00:00:00:02", second, "second distinct MAC should get the next placeholder")
tests := []struct {
name string
mac string
}{
{"Broadcast", "ff:ff:ff:ff:ff:ff"},
{"IPv4 multicast", "01:00:5e:00:00:fb"},
{"IPv6 multicast", "33:33:00:00:00:01"},
{"All zero", "00:00:00:00:00:00"},
{"Assigned placeholder", "02:00:00:00:00:01"},
{"Invalid", "not-a-mac"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.mac, anonymizer.AnonymizeMAC(tc.mac), "should be preserved")
})
}
}
func TestAnonymizeString_MACAddresses(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
tests := []struct {
name string
input string
expect string
}{
{
name: "nftables ether rule",
input: "ether saddr aa:bb:cc:dd:ee:ff drop",
expect: "ether saddr 02:00:00:00:00:01 drop",
},
{
name: "Windows dash form",
input: "Physical Address : AA-BB-CC-DD-EE-FF",
expect: "Physical Address : 02-00-00-00-00-01",
},
{
name: "IPv6 address tail is not treated as MAC",
input: "addr fe80:0:11:22:33:44:55:66 scope link",
expect: "addr fe80:0:11:22:33:44:55:66 scope link",
},
{
name: "broadcast MAC preserved",
input: "dst ff:ff:ff:ff:ff:ff type ARP",
expect: "dst ff:ff:ff:ff:ff:ff type ARP",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := anonymizer.AnonymizeString(tc.input)
assert.Equal(t, tc.expect, result, "MAC addresses should be anonymized at every level")
assert.Equal(t, result, anonymizer.AnonymizeString(result), "second pass should not change the result")
})
}
}
func TestAnonymizeWGKey(t *testing.T) {
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
t.Run("default level preserves keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
assert.Equal(t, key, anonymizer.AnonymizeWGKey(key), "default level should not touch WireGuard keys")
})
t.Run("strict level replaces keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
anon := anonymizer.AnonymizeWGKey(key)
assert.NotEqual(t, key, anon, "strict level should replace the key")
assert.Regexp(t, `^[A-Za-z0-9+/]{43}=$`, anon, "placeholder should keep the WireGuard key shape")
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(key), "repeated key should map to the same placeholder")
assert.Equal(t, anon, anonymizer.AnonymizeWGKey(anon), "an assigned placeholder should pass through unchanged")
assert.Equal(t, "not-a-key", anonymizer.AnonymizeWGKey("not-a-key"), "non-key values should be preserved")
})
}
func TestAnonymizeString_WGKeys(t *testing.T) {
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32))
input := "peer " + key + " handshake completed"
t.Run("default level preserves keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
assert.Equal(t, input, anonymizer.AnonymizeString(input), "default level should not touch WireGuard keys in strings")
})
t.Run("strict level replaces keys", func(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
firstPass := anonymizer.AnonymizeString(input)
assert.NotContains(t, firstPass, key, "the key should not survive strict anonymization")
assert.Equal(t, anonymizer.AnonymizeWGKey(key), extractKey(t, firstPass), "string replacement should be consistent with AnonymizeWGKey")
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
})
}
func extractKey(t *testing.T, logLine string) string {
t.Helper()
fields := strings.Fields(logLine)
require.Len(t, fields, 4, "log line should keep its structure")
return fields[1]
}
func TestAnonymizeDomain_StrictLevel(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
t.Run("netbird peer name", func(t *testing.T) {
result := anonymizer.AnonymizeDomain("my-laptop.netbird.cloud")
assert.Regexp(t, `^peer-\d+\.netbird\.cloud$`, result, "peer name should be anonymized, suffix kept")
assert.NotContains(t, result, "my-laptop", "the peer name should not survive")
assert.Equal(t, result, anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), "repeated domain should map consistently")
assert.Equal(t, result, anonymizer.AnonymizeDomain(result), "an anonymized domain should pass through unchanged")
})
t.Run("bare netbird domain", func(t *testing.T) {
assert.Equal(t, "netbird.cloud", anonymizer.AnonymizeDomain("netbird.cloud"), "the bare protected suffix should be preserved")
})
t.Run("netbird infrastructure preserved", func(t *testing.T) {
assert.Equal(t, "api.netbird.io", anonymizer.AnonymizeDomain("api.netbird.io"),
"netbird.io hosts infrastructure, not peer names, and should stay readable")
})
t.Run("leading labels of other domains", func(t *testing.T) {
result := anonymizer.AnonymizeDomain("host1.corp.example.com")
assert.Regexp(t, `^host-\d+\.host-\d+\.anon-[a-zA-Z0-9]+\.domain$`, result, "every label should be anonymized")
for _, label := range []string{"host1", "corp", "example"} {
assert.NotContains(t, result, label, "no original label should survive")
}
assert.Equal(t, result, anonymizer.AnonymizeDomain("host1.corp.example.com"), "repeated domain should map consistently")
})
t.Run("same label maps consistently across domains", func(t *testing.T) {
first := anonymizer.AnonymizeDomain("shared.one.com")
second := anonymizer.AnonymizeDomain("shared.two.com")
assert.Equal(t, strings.Split(first, ".")[0], strings.Split(second, ".")[0], "the shared host label should get one placeholder")
})
t.Run("wildcard label preserved", func(t *testing.T) {
result := anonymizer.AnonymizeDomain("*.example.com")
assert.Regexp(t, `^\*\.anon-[a-zA-Z0-9]+\.domain$`, result, "the wildcard label should stay a wildcard")
})
}
func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
assert.Equal(t, "my-laptop.netbird.cloud", anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"),
"default level should preserve netbird FQDNs including the peer name")
assert.Regexp(t, `^sub\.anon-[a-zA-Z0-9]+\.domain$`, anonymizer.AnonymizeDomain("sub.example.com"),
"default level should keep subdomain labels")
}
func TestAnonymizeString_StrictPeerNames(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(anonymize.LevelStrict)
// Seed like the bundle generator does from the status: base first, then
// the full FQDN, so replacement must prefer the longer mapping.
anonBase := anonymizer.AnonymizeDomain("example.com")
anonPeer := anonymizer.AnonymizeDomain("peer1.netbird.cloud")
anonHost := anonymizer.AnonymizeDomain("host1.example.com")
logLine := "connected to peer1.netbird.cloud via host1.example.com endpoint"
firstPass := anonymizer.AnonymizeString(logLine)
assert.NotContains(t, firstPass, "peer1", "the peer name should not survive in logs")
assert.NotContains(t, firstPass, "host1", "the host label should not survive in logs")
assert.Contains(t, firstPass, anonPeer, "the seeded peer mapping should be applied")
assert.Contains(t, firstPass, anonHost, "the seeded host mapping should be applied, not just the base mapping")
assert.NotContains(t, firstPass, "host1."+anonBase, "the base mapping must not preempt the longer FQDN mapping")
assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result")
}
func TestAnonymizeDNSLogLine(t *testing.T) {
anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{})
tests := []struct {

View File

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

View File

@@ -21,6 +21,7 @@ import (
"github.com/spf13/pflag"
"google.golang.org/grpc"
"github.com/netbirdio/netbird/client/anonymize"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
@@ -69,6 +70,7 @@ var (
autoConnectDisabled bool
extraIFaceBlackList []string
anonymizeFlag bool
anonymizeLevelFlag string
dnsRouteInterval time.Duration
// lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection
// flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead).
@@ -156,7 +158,8 @@ func init() {
rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file")
rootCmd.PersistentFlags().StringVar(&preSharedKey, preSharedKeyFlag, "", "Sets WireGuard PreSharedKey property. If set, then only peers that have the same key can communicate.")
rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device")
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output")
rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize public IP addresses, MAC addresses, and non-netbird.io domains in logs and status output; private, CGNAT, and link-local IP ranges are kept (see --anonymize-level strict)")
rootCmd.PersistentFlags().StringVar(&anonymizeLevelFlag, "anonymize-level", "", "anonymization level: \"default\" or \"strict\"; strict also anonymizes private, CGNAT, and link-local IP ranges, peer names, and WireGuard public keys. Setting this flag implies --anonymize")
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location")
rootCmd.AddCommand(upCmd)
@@ -293,6 +296,19 @@ var CLIBackOffSettings = &backoff.ExponentialBackOff{
Clock: backoff.SystemClock,
}
// effectiveAnonymize resolves the --anonymize and --anonymize-level flags:
// setting a level implies anonymization, and an invalid level is rejected.
func effectiveAnonymize() (bool, anonymize.Level, error) {
if anonymizeLevelFlag == "" {
return anonymizeFlag, anonymize.LevelDefault, nil
}
level := anonymize.ParseLevel(anonymizeLevelFlag)
if !strings.EqualFold(anonymizeLevelFlag, level.String()) {
return false, anonymize.LevelDefault, fmt.Errorf("invalid anonymize level %q: use %q or %q", anonymizeLevelFlag, anonymize.LevelDefault.String(), anonymize.LevelStrict.String())
}
return true, level, nil
}
func getSetupKey() (string, error) {
if setupKeyPath != "" && setupKey == "" {
return getSetupKeyFromFile(setupKeyPath)

View File

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

View File

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

View File

@@ -42,11 +42,12 @@ type aclManager struct {
optionalEntries map[string][]entry
ipsetStore *ipsetStore
v6 bool
ipsetSupport *ipsetSupport
stateManager *statemanager.Manager
}
func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*aclManager, error) {
func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper, ipsetSupport *ipsetSupport) (*aclManager, error) {
return &aclManager{
iptablesClient: iptablesClient,
wgIface: wgIface,
@@ -54,6 +55,7 @@ func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*acl
optionalEntries: make(map[string][]entry),
ipsetStore: newIpsetStore(),
v6: iptablesClient.Proto() == iptables.ProtocolIPv6,
ipsetSupport: ipsetSupport,
}, nil
}
@@ -85,12 +87,77 @@ func (m *aclManager) AddPeerFiltering(
action firewall.Action,
ipsetName string,
) ([]firewall.Rule, error) {
chain := chainNameInputRules
ipsetName = m.resolveIPSetName(ipsetName, sPort, dPort, action)
if ipsetName == "" {
return m.addPeerRule(ip, protocol, sPort, dPort, action, "")
}
// A set that is already in the store backs rules installed earlier, so it must
// survive this call's failure.
_, preexisting := m.ipsetStore.ipset(ipsetName)
rules, err := m.addPeerRule(ip, protocol, sPort, dPort, action, ipsetName)
if err == nil {
return rules, nil
}
var unusable *ipsetUnusableError
if !errors.As(err, &unusable) {
return nil, err
}
// The set could not be created or matched. Drop the one this call created and
// retry the rule matching the IP directly; only if that succeeds do we know
// ipset was to blame and latch it off for subsequent rules.
if !preexisting {
m.discardIPSet(ipsetName)
}
rules, retryErr := m.addPeerRule(ip, protocol, sPort, dPort, action, "")
if retryErr != nil {
return nil, fmt.Errorf("add peer rule (ipset: %w): %w", unusable.cause, retryErr)
}
m.ipsetSupport.markUnsupported(unusable.cause)
return rules, nil
}
// resolveIPSetName derives the ipset name for a rule, returning "" when the rule
// must match the IP directly: either no set was requested or ipset is unusable.
func (m *aclManager) resolveIPSetName(ipsetName string, sPort, dPort *firewall.Port, action firewall.Action) string {
ipsetName = transformIPsetName(ipsetName, sPort, dPort, action)
if m.v6 && ipsetName != "" {
if ipsetName == "" || !m.ipsetSupport.supported() {
return ""
}
if m.v6 {
ipsetName += "-v6"
}
return ipsetName
}
// discardIPSet removes a set that turned out to be unusable, so a later rule
// does not find it in the store and assume it works.
func (m *aclManager) discardIPSet(ipsetName string) {
m.ipsetStore.deleteIpset(ipsetName)
if err := m.destroyIPSet(ipsetName); err != nil {
log.Debugf("destroy unusable ipset %s: %v", ipsetName, err)
}
}
func (m *aclManager) addPeerRule(
ip net.IP,
protocol firewall.Protocol,
sPort *firewall.Port,
dPort *firewall.Port,
action firewall.Action,
ipsetName string,
) ([]firewall.Rule, error) {
chain := chainNameInputRules
proto := protoForFamily(protocol, m.v6)
specs := filterRuleSpecs(ip, proto, sPort, dPort, action, ipsetName)
@@ -105,7 +172,7 @@ func (m *aclManager) AddPeerFiltering(
if ipsetName != "" {
if ipList, ipsetExists := m.ipsetStore.ipset(ipsetName); ipsetExists {
if err := m.addToIPSet(ipsetName, ip); err != nil {
return nil, fmt.Errorf("add IP to ipset: %w", err)
return nil, ipsetUnusable(fmt.Errorf("add IP to ipset: %w", err))
}
// if ruleset already exists it means we already have the firewall rule
// so we need to update IPs in the ruleset and return new fw.Rule object for ACL manager.
@@ -128,10 +195,10 @@ func (m *aclManager) AddPeerFiltering(
}
}
if err := m.createIPSet(ipsetName); err != nil {
return nil, fmt.Errorf("create ipset: %w", err)
return nil, ipsetUnusable(fmt.Errorf("create ipset: %w", err))
}
if err := m.addToIPSet(ipsetName, ip); err != nil {
return nil, fmt.Errorf("add IP to ipset: %w", err)
return nil, ipsetUnusable(fmt.Errorf("add IP to ipset: %w", err))
}
ipList := newIpList(ip.String())
@@ -140,7 +207,7 @@ func (m *aclManager) AddPeerFiltering(
ok, err := m.iptablesClient.Exists(tableFilter, chain, specs...)
if err != nil {
return nil, fmt.Errorf("failed to check rule: %w", err)
return nil, maybeIPSetUnusable(ipsetName, fmt.Errorf("check rule: %w", err))
}
if ok {
return nil, fmt.Errorf("rule already exists")
@@ -154,7 +221,7 @@ func (m *aclManager) AddPeerFiltering(
err = m.iptablesClient.Append(tableFilter, chain, specs...)
}
if err != nil {
return nil, err
return nil, maybeIPSetUnusable(ipsetName, err)
}
if err := m.iptablesClient.Append(tableMangle, chainRTPRE, mangleSpecs...); err != nil {

View File

@@ -0,0 +1,240 @@
//go:build privileged
package iptables
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
fw "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/wgaddr"
)
func iptRefcountIfaceV4() *iFaceMock {
return &iFaceMock{
NameFunc: func() string { return "wt-refcount" },
AddressFunc: func() wgaddr.Address {
return wgaddr.Address{
IP: netip.MustParseAddr("10.20.0.1"),
Network: netip.MustParsePrefix("10.20.0.0/24"),
}
},
}
}
func iptRefcountIfaceDual() *iFaceMock {
return &iFaceMock{
NameFunc: func() string { return "wt-refcount" },
AddressFunc: func() wgaddr.Address {
return wgaddr.Address{
IP: netip.MustParseAddr("10.20.0.1"),
Network: netip.MustParsePrefix("10.20.0.0/24"),
IPv6: netip.MustParseAddr("fd00::1"),
IPv6Net: netip.MustParsePrefix("fd00::/64"),
}
},
}
}
func newIptRefcountManager(t *testing.T, dual bool) *Manager {
t.Helper()
var ifMock *iFaceMock
if dual {
ifMock = iptRefcountIfaceDual()
} else {
ifMock = iptRefcountIfaceV4()
}
m, err := Create(ifMock, iface.DefaultMTU)
require.NoError(t, err, "create manager")
require.NoError(t, m.Init(nil), "init manager")
t.Cleanup(func() {
require.NoError(t, m.Close(nil), "close manager")
})
return m
}
func iptDnatV4(port uint16) fw.ForwardRule {
return fw.ForwardRule{
Protocol: fw.ProtocolTCP,
DestinationPort: fw.Port{Values: []uint16{port}},
TranslatedAddress: netip.MustParseAddr("10.20.0.2"),
TranslatedPort: fw.Port{Values: []uint16{80}},
}
}
func iptDnatV6(port uint16) fw.ForwardRule {
return fw.ForwardRule{
Protocol: fw.ProtocolTCP,
DestinationPort: fw.Port{Values: []uint16{port}},
TranslatedAddress: netip.MustParseAddr("fd00::2"),
TranslatedPort: fw.Port{Values: []uint16{80}},
}
}
// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
// (called on every network-map update) holds at most one reference per family
// and a single DisableRouting drops both back to zero.
func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) {
m := newIptRefcountManager(t, true)
state := m.router.ipFwdState
require.NoError(t, m.EnableRouting(), "first enable")
require.NoError(t, m.EnableRouting(), "second enable")
require.NoError(t, m.EnableRouting(), "third enable")
v4, v6 := state.Counts()
assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
require.NoError(t, m.DisableRouting(), "disable")
v4, v6 = state.Counts()
assert.Equal(t, 0, v4, "single disable releases the v4 reference")
assert.Equal(t, 0, v6, "single disable releases the v6 reference")
}
// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired
// DisableRouting does not release references held by active DNAT rules.
func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) {
m := newIptRefcountManager(t, true)
state := m.router.ipFwdState
r1, err := m.AddDNATRule(iptDnatV6(9095))
require.NoError(t, err, "add v6 dnat")
require.NoError(t, m.DisableRouting(), "unpaired disable")
_, v6 := state.Counts()
assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
_, v6 = state.Counts()
assert.Equal(t, 0, v6, "delete releases the DNAT reference")
}
// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4.
func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) {
m := newIptRefcountManager(t, false)
state := m.router.ipFwdState
r1, err := m.AddDNATRule(iptDnatV4(7081))
require.NoError(t, err, "add v4 dnat 1")
v4, v6 := state.Counts()
assert.Equal(t, 1, v4, "v4 refcount after first add")
assert.Equal(t, 0, v6, "v6 refcount unchanged")
r2, err := m.AddDNATRule(iptDnatV4(7082))
require.NoError(t, err, "add v4 dnat 2")
v4, v6 = state.Counts()
assert.Equal(t, 2, v4, "v4 refcount after second add")
assert.Equal(t, 0, v6, "v6 refcount unchanged")
require.NoError(t, m.DeleteDNATRule(r1))
v4, v6 = state.Counts()
assert.Equal(t, 1, v4, "v4 refcount after first delete")
assert.Equal(t, 0, v6, "v6 refcount unchanged")
require.NoError(t, m.DeleteDNATRule(r2))
v4, v6 = state.Counts()
assert.Equal(t, 0, v4, "v4 refcount after second delete")
assert.Equal(t, 0, v6, "v6 refcount unchanged")
}
// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and
// decrements back to zero.
func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) {
m := newIptRefcountManager(t, true)
require.NotNil(t, m.router6, "v6 router")
require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state")
state := m.router.ipFwdState
r1, err := m.AddDNATRule(iptDnatV6(9081))
require.NoError(t, err, "add v6 dnat 1")
v4, v6 := state.Counts()
assert.Equal(t, 0, v4)
assert.Equal(t, 1, v6, "v6 refcount after first add")
r2, err := m.AddDNATRule(iptDnatV6(9082))
require.NoError(t, err, "add v6 dnat 2")
v4, v6 = state.Counts()
assert.Equal(t, 0, v4, "v4 refcount unchanged")
assert.Equal(t, 2, v6, "v6 refcount after second add")
require.NoError(t, m.DeleteDNATRule(r1))
v4, v6 = state.Counts()
assert.Equal(t, 0, v4, "v4 refcount unchanged")
assert.Equal(t, 1, v6, "v6 refcount after first delete")
require.NoError(t, m.DeleteDNATRule(r2))
v4, v6 = state.Counts()
assert.Equal(t, 0, v4)
assert.Equal(t, 0, v6, "v6 refcount after second delete")
}
// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns
// without bumping the refcount.
func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) {
m := newIptRefcountManager(t, true)
state := m.router.ipFwdState
rule := iptDnatV4(7083)
r1, err := m.AddDNATRule(rule)
require.NoError(t, err)
v4, _ := state.Counts()
assert.Equal(t, 1, v4)
_, err = m.AddDNATRule(rule)
require.NoError(t, err, "duplicate add")
v4, _ = state.Counts()
assert.Equal(t, 1, v4, "duplicate add must not increment")
require.NoError(t, m.DeleteDNATRule(r1))
v4, _ = state.Counts()
assert.Equal(t, 0, v4, "single delete must drop to zero")
}
// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule
// neither errors nor releases the refcount.
func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
m := newIptRefcountManager(t, true)
state := m.router.ipFwdState
phantom := iptDnatV4(7099)
require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4")
v4, v6 := state.Counts()
assert.Equal(t, 0, v4)
assert.Equal(t, 0, v6)
phantom6 := iptDnatV6(9099)
require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6")
v4, v6 = state.Counts()
assert.Equal(t, 0, v4)
assert.Equal(t, 0, v6)
r1, err := m.AddDNATRule(iptDnatV4(7100))
require.NoError(t, err)
v4, _ = state.Counts()
assert.Equal(t, 1, v4, "real add still increments after phantom delete")
require.NoError(t, m.DeleteDNATRule(r1))
}
// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same
// rule is a no-op.
func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
m := newIptRefcountManager(t, true)
state := m.router.ipFwdState
r1, err := m.AddDNATRule(iptDnatV6(9083))
require.NoError(t, err)
_, v6 := state.Counts()
assert.Equal(t, 1, v6)
require.NoError(t, m.DeleteDNATRule(r1), "first delete")
_, v6 = state.Counts()
assert.Equal(t, 0, v6)
require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
_, v6 = state.Counts()
assert.Equal(t, 0, v6, "double delete must not underflow")
}

View File

@@ -0,0 +1,75 @@
package iptables
import (
"sync"
log "github.com/sirupsen/logrus"
)
// ipsetSupport tracks whether ipset-backed firewall rules can be installed.
//
// It starts optimistic and latches to unsupported the first time the kernel
// proves otherwise: either the hash:net set type is missing (ip_set_hash_net) or
// iptables cannot match against a set (xt_set). Callers then emit per-IP and
// per-prefix rules instead. Without the fallback, a rule referencing an unusable
// set is never installed and the catch-all DROP silently blocks traffic the
// policy permits.
//
// One instance is shared by the ACL managers and routers of both address
// families, because ipset availability is a property of the kernel rather than
// of any single table.
type ipsetSupport struct {
mu sync.RWMutex
unsupported bool
}
func newIPSetSupport() *ipsetSupport {
return &ipsetSupport{}
}
func (s *ipsetSupport) supported() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return !s.unsupported
}
// markUnsupported records that ipset cannot be used, logging the reason once.
func (s *ipsetSupport) markUnsupported(cause error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.unsupported {
return
}
s.unsupported = true
log.Warnf("ipset is unavailable (%v); falling back to per-IP firewall rules. "+
"Ensure the kernel provides ip_set_hash_net and xt_set; without them rule "+
"sets are larger and slower to converge on networks with many peers", cause)
}
// ipsetUnusableError marks a failure attributable to ipset, so the caller can
// retry the same rule in its per-IP form before latching the capability off.
type ipsetUnusableError struct {
cause error
}
func (e *ipsetUnusableError) Error() string { return e.cause.Error() }
func (e *ipsetUnusableError) Unwrap() error { return e.cause }
func ipsetUnusable(cause error) error {
return &ipsetUnusableError{cause: cause}
}
// maybeIPSetUnusable marks an iptables failure as ipset-attributable only when the
// rule actually carried a set match, since the same call can fail for unrelated
// reasons on a rule that matches addresses directly.
func maybeIPSetUnusable(ipsetName string, err error) error {
if ipsetName == "" {
return err
}
return ipsetUnusable(err)
}

View File

@@ -33,6 +33,10 @@ type Manager struct {
router *router
rawSupported bool
// ipsetSupport is shared by the ACL managers and routers of both families,
// so a kernel without usable ipset support degrades them together.
ipsetSupport *ipsetSupport
// IPv6 counterparts, nil when no v6 overlay
ipv6Client *iptables.IPTables
aclMgr6 *aclManager
@@ -53,16 +57,17 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) {
}
m := &Manager{
wgIface: wgIface,
ipv4Client: iptablesClient,
wgIface: wgIface,
ipv4Client: iptablesClient,
ipsetSupport: newIPSetSupport(),
}
m.router, err = newRouter(iptablesClient, wgIface, mtu)
m.router, err = newRouter(iptablesClient, wgIface, mtu, m.ipsetSupport)
if err != nil {
return nil, fmt.Errorf("create router: %w", err)
}
m.aclMgr, err = newAclManager(iptablesClient, wgIface)
m.aclMgr, err = newAclManager(iptablesClient, wgIface, m.ipsetSupport)
if err != nil {
return nil, fmt.Errorf("create acl manager: %w", err)
}
@@ -83,16 +88,16 @@ func (m *Manager) createIPv6Components(wgIface iFaceMapper, mtu uint16) error {
}
m.ipv6Client = ip6Client
m.router6, err = newRouter(ip6Client, wgIface, mtu)
m.router6, err = newRouter(ip6Client, wgIface, mtu, m.ipsetSupport)
if err != nil {
return fmt.Errorf("create v6 router: %w", err)
}
// Share the same IP forwarding state with the v4 router, since
// EnableIPForwarding controls both v4 and v6 sysctls.
// Forwarding refcounter is per-family but shared between v4 and v6 routers.
m.router6.ipFwdState = m.router.ipFwdState
m.aclMgr6, err = newAclManager(ip6Client, wgIface)
m.aclMgr6, err = newAclManager(ip6Client, wgIface, m.ipsetSupport)
if err != nil {
return fmt.Errorf("create v6 acl manager: %w", err)
}
@@ -402,17 +407,12 @@ func (m *Manager) SetLogLevel(log.Level) {
}
func (m *Manager) EnableRouting() error {
if err := m.router.ipFwdState.RequestForwarding(); err != nil {
return fmt.Errorf("enable IP forwarding: %w", err)
}
return nil
// v6 only when the overlay actually has v6.
return m.router.ipFwdState.RequestRouting(m.router6 != nil)
}
func (m *Manager) DisableRouting() error {
if err := m.router.ipFwdState.ReleaseForwarding(); err != nil {
return fmt.Errorf("disable IP forwarding: %w", err)
}
return nil
return m.router.ipFwdState.ReleaseRouting()
}
// AddDNATRule adds a DNAT rule

View File

@@ -291,3 +291,98 @@ func TestIptablesCreatePerformance(t *testing.T) {
})
}
}
// newACLTestManager returns a started manager. Create()/Init() is used so the
// router-owned chains (chainRTFWDIN/OUT) exist before the ACL manager's
// createDefaultChains() references them.
func newACLTestManager(t *testing.T) *Manager {
t.Helper()
manager, err := Create(ifaceMock, iface.DefaultMTU)
require.NoError(t, err)
require.NoError(t, manager.Init(nil))
t.Cleanup(func() {
require.NoError(t, manager.Close(nil))
})
return manager
}
// TestIptablesACLUsesIPSetOnHealthyKernel guards the default: on a kernel that
// does have ipset, rules must keep matching a set. A regression that reported
// ipset as unusable would silently move every Linux client to per-IP rules.
func TestIptablesACLUsesIPSetOnHealthyKernel(t *testing.T) {
ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
require.NoError(t, err)
manager := newACLTestManager(t)
ip := netip.MustParseAddr("10.20.0.42")
port := &fw.Port{Values: []uint16{22}}
rules, err := manager.aclMgr.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "nb0000001")
require.NoError(t, err)
require.NotEmpty(t, rules)
rule := rules[0].(*Rule)
require.Equal(t, "nb0000001-dport", rule.ipsetName, "healthy kernel must use an ipset")
require.Contains(t, rule.specs, "--match-set")
require.True(t, manager.ipsetSupport.supported(), "ipset must not be latched off on a healthy kernel")
checkRuleSpecs(t, ipv4Client, rule.chain, true, rule.specs...)
}
// TestIptablesACLFallsBackWhenIPSetUnusable drives the real failure path: an
// oversized set name is rejected by the kernel, which stands in for a kernel
// without ip_set_hash_net or xt_set. The rule must still land in the chain,
// matching the IP directly, and the capability must latch off so later rules skip
// ipset. Before the fallback existed, the rule was dropped and the catch-all DROP
// silently blocked traffic the policy permits.
func TestIptablesACLFallsBackWhenIPSetUnusable(t *testing.T) {
ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
require.NoError(t, err)
manager := newACLTestManager(t)
// ipset names are limited to 31 characters, so creating this set fails.
unusableName := strings.Repeat("a", 40)
ip := netip.MustParseAddr("10.20.0.42")
port := &fw.Port{Values: []uint16{22}}
rules, err := manager.aclMgr.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, unusableName)
require.NoError(t, err, "AddPeerFiltering must succeed by falling back")
require.NotEmpty(t, rules)
rule := rules[0].(*Rule)
require.Empty(t, rule.ipsetName, "fallback rule must not reference an ipset")
require.Contains(t, strings.Join(rule.specs, " "), "-s 10.20.0.42", "fallback rule must match the source IP")
require.NotContains(t, strings.Join(rule.specs, " "), "--match-set")
// The rule must actually be present, not silently missing.
checkRuleSpecs(t, ipv4Client, rule.chain, true, rule.specs...)
require.False(t, manager.ipsetSupport.supported(), "failure must latch ipset off")
// A subsequent rule with a perfectly valid set name now skips ipset too.
next, err := manager.aclMgr.AddPeerFiltering(nil, netip.MustParseAddr("10.20.0.43").AsSlice(), "tcp", nil, port, fw.ActionAccept, "nb0000001")
require.NoError(t, err)
require.NotEmpty(t, next)
require.Empty(t, next[0].(*Rule).ipsetName, "later rules must skip ipset once latched")
}
// TestIptablesACLLeavesNoIPSetAfterFallback verifies the set created before the
// failure is destroyed, so a later rule does not find a half-built set and assume
// ipset works.
func TestIptablesACLLeavesNoIPSetAfterFallback(t *testing.T) {
manager := newACLTestManager(t)
port := &fw.Port{Values: []uint16{22}}
ip := netip.MustParseAddr("10.20.0.42")
_, err := manager.aclMgr.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, strings.Repeat("a", 40))
require.NoError(t, err)
_, exists := manager.aclMgr.ipsetStore.ipset(strings.Repeat("a", 40) + "-dport")
require.False(t, exists, "unusable set must not stay in the store")
}

View File

@@ -3,6 +3,7 @@
package iptables
import (
"errors"
"fmt"
"maps"
"net/netip"
@@ -51,6 +52,10 @@ const (
markManglePost = "mark-mangle-post"
matchSet = "--match-set"
// routeSourceSuffix names the extra rules a route ACL needs when ipset is
// unusable and each source prefix has to be matched by its own rule.
routeSourceSuffix = "_src"
dnatSuffix = "_dnat"
snatSuffix = "_snat"
fwdSuffix = "_fwd"
@@ -68,7 +73,6 @@ type ruleInfo struct {
}
type routeFilteringRuleParams struct {
Source firewall.Network
Destination firewall.Network
Proto firewall.Protocol
SPort *firewall.Port
@@ -90,19 +94,21 @@ type router struct {
legacyManagement bool
mtu uint16
v6 bool
ipsetSupport *ipsetSupport
stateManager *statemanager.Manager
ipFwdState *ipfwdstate.IPForwardingState
}
func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint16) (*router, error) {
func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint16, ipsetSupport *ipsetSupport) (*router, error) {
r := &router{
iptablesClient: iptablesClient,
rules: make(map[string][]string),
wgIface: wgIface,
mtu: mtu,
v6: iptablesClient.Proto() == iptables.ProtocolIPv6,
ipFwdState: ipfwdstate.NewIPForwardingState(),
ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()),
ipsetSupport: ipsetSupport,
}
r.ipsetCounter = refcounter.New(
@@ -151,15 +157,7 @@ func (r *router) AddRouteFiltering(
return ruleKey, nil
}
var source firewall.Network
if len(sources) > 1 {
source.Set = firewall.NewPrefixSet(sources)
} else if len(sources) > 0 {
source.Prefix = sources[0]
}
params := routeFilteringRuleParams{
Source: source,
Destination: destination,
Proto: proto,
SPort: sPort,
@@ -167,30 +165,156 @@ func (r *router) AddRouteFiltering(
Action: action,
}
rule, err := r.genRouteRuleSpec(params, sources)
if err != nil {
return nil, fmt.Errorf("generate route rule spec: %w", err)
}
err := r.installRouteRules(string(ruleKey), params, sources, r.ipsetSupport.supported())
// Insert DROP rules at the beginning, append ACCEPT rules at the end
if action == firewall.ActionDrop {
// after the established rule
err = r.iptablesClient.Insert(tableFilter, chainRTFWDIN, 2, rule...)
} else {
err = r.iptablesClient.Append(tableFilter, chainRTFWDIN, rule...)
var unusable *ipsetUnusableError
if errors.As(err, &unusable) {
// The set could not be created or matched. Retry matching each source
// prefix on its own; only if that works do we know ipset was to blame.
r.removeRouteRules(string(ruleKey))
if retryErr := r.installRouteRules(string(ruleKey), params, sources, false); retryErr != nil {
r.removeRouteRules(string(ruleKey))
return nil, fmt.Errorf("add route rule (ipset: %w): %w", unusable.cause, retryErr)
}
r.ipsetSupport.markUnsupported(unusable.cause)
err = nil
}
if err != nil {
return nil, fmt.Errorf("add route rule: %v", err)
}
// Leave nothing half-installed: a later call finding the rule key would
// report success while some sources were never installed, which for a
// drop rule would leave them unblocked.
r.removeRouteRules(string(ruleKey))
r.rules[string(ruleKey)] = rule
return nil, fmt.Errorf("add route rule: %w", err)
}
r.updateState()
return ruleKey, nil
}
// installRouteRules installs every rule needed for one route ACL and records them
// under ruleKey. It is more than one rule only when useIPSet is false and the
// sources have to be matched one prefix at a time.
func (r *router) installRouteRules(ruleKey string, params routeFilteringRuleParams, sources []netip.Prefix, useIPSet bool) error {
specs, err := r.genRouteRuleSpecs(params, sources, useIPSet)
if err != nil {
return fmt.Errorf("generate route rule spec: %w", err)
}
for i, spec := range specs {
if err := r.insertRouteRule(params.Action, spec); err != nil {
if len(r.findSets(spec)) > 0 {
return ipsetUnusable(err)
}
return err
}
r.rules[routeRuleKey(ruleKey, i)] = spec
}
return nil
}
// genRouteRuleSpecs builds the rules for one route ACL. With ipset available that
// is a single rule matching a set of sources; without it, one rule per source
// prefix, which is the only form a stripped kernel can express.
func (r *router) genRouteRuleSpecs(params routeFilteringRuleParams, sources []netip.Prefix, useIPSet bool) ([][]string, error) {
destExp, err := r.applyNetwork("-d", params.Destination, nil)
if err != nil {
return nil, fmt.Errorf("apply network -d: %w", err)
}
specs, err := r.genSourceRules(params, sources, useIPSet, destExp)
if err != nil {
// The destination match may have taken a set reference already.
if decErr := r.decrementSetCounter(destExp); decErr != nil {
log.Debugf("release destination set after failed rule generation: %v", decErr)
}
return nil, err
}
return specs, nil
}
func (r *router) genSourceRules(params routeFilteringRuleParams, sources []netip.Prefix, useIPSet bool, destExp []string) ([][]string, error) {
if useIPSet || len(sources) <= 1 {
sourceExp, err := r.applyNetwork("-s", sourceNetwork(sources), sources)
if err != nil {
return nil, fmt.Errorf("apply network -s: %w", err)
}
return [][]string{assembleRouteRule(sourceExp, destExp, params, r.v6)}, nil
}
specs := make([][]string, 0, len(sources))
for _, source := range sources {
sourceExp, err := r.applyNetwork("-s", firewall.Network{Prefix: source}, nil)
if err != nil {
return nil, fmt.Errorf("apply network -s: %w", err)
}
specs = append(specs, assembleRouteRule(sourceExp, destExp, params, r.v6))
}
return specs, nil
}
func sourceNetwork(sources []netip.Prefix) firewall.Network {
var source firewall.Network
if len(sources) > 1 {
source.Set = firewall.NewPrefixSet(sources)
} else if len(sources) > 0 {
source.Prefix = sources[0]
}
return source
}
func (r *router) insertRouteRule(action firewall.Action, spec []string) error {
// Insert DROP rules at the beginning, append ACCEPT rules at the end
if action == firewall.ActionDrop {
// after the established rule
return r.iptablesClient.Insert(tableFilter, chainRTFWDIN, 2, spec...)
}
return r.iptablesClient.Append(tableFilter, chainRTFWDIN, spec...)
}
// removeRouteRules deletes the rules recorded for ruleKey, used to undo a partial
// install before retrying without ipset.
func (r *router) removeRouteRules(ruleKey string) {
for i := 0; ; i++ {
key := routeRuleKey(ruleKey, i)
spec, exists := r.rules[key]
if !exists {
return
}
if err := r.iptablesClient.DeleteIfExists(tableFilter, chainRTFWDIN, spec...); err != nil {
log.Debugf("delete partial route rule %s: %v", key, err)
}
delete(r.rules, key)
if err := r.decrementSetCounter(spec); err != nil {
log.Debugf("decrement ipset counter for %s: %v", key, err)
}
}
}
// routeRuleKey names the i-th rule of a route ACL. The first keeps the plain rule
// key so single-rule ACLs, which is every ACL when ipset works, are unaffected.
func routeRuleKey(ruleKey string, i int) string {
if i == 0 {
return ruleKey
}
return fmt.Sprintf("%s%s%d", ruleKey, routeSourceSuffix, i)
}
func (r *router) hasRule(id string) bool {
_, ok := r.rules[id]
return ok
@@ -199,17 +323,29 @@ func (r *router) hasRule(id string) bool {
func (r *router) DeleteRouteRule(rule firewall.Rule) error {
ruleKey := rule.ID()
if rule, exists := r.rules[ruleKey]; exists {
if _, exists := r.rules[ruleKey]; !exists {
log.Debugf("route rule %s not found", ruleKey)
r.updateState()
return nil
}
// In the ipset fallback one ACL is several rules, one per source prefix.
for i := 0; ; i++ {
key := routeRuleKey(ruleKey, i)
rule, exists := r.rules[key]
if !exists {
break
}
if err := r.iptablesClient.Delete(tableFilter, chainRTFWDIN, rule...); err != nil {
return fmt.Errorf("delete route rule: %v", err)
}
delete(r.rules, ruleKey)
delete(r.rules, key)
if err := r.decrementSetCounter(rule); err != nil {
return fmt.Errorf("decrement ipset counter: %w", err)
}
} else {
log.Debugf("route rule %s not found", ruleKey)
}
r.updateState()
@@ -770,10 +906,6 @@ func (r *router) updateState() {
}
func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
if err := r.ipFwdState.RequestForwarding(); err != nil {
return nil, err
}
ruleKey := rule.ID()
if _, exists := r.rules[ruleKey+dnatSuffix]; exists {
return rule, nil
@@ -840,18 +972,34 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
for key, ruleInfo := range rules {
if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil {
if rollbackErr := r.rollbackRules(rules); rollbackErr != nil {
log.Errorf("rollback failed: %v", rollbackErr)
}
r.cleanupFailedDNATAdd(rules)
return nil, fmt.Errorf("add rule %s: %w", key, err)
}
r.rules[key] = ruleInfo.rule
}
if err := r.ipFwdState.RequestForwarding(r.v6); err != nil {
r.cleanupFailedDNATAdd(rules)
return nil, fmt.Errorf("enable forwarding: %w", err)
}
r.updateState()
return rule, nil
}
// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied
// AddDNATRule before rolling back the kernel rules, so no entries remain that
// never got a forwarding refcount. rollbackRules re-adds entries it failed to
// remove from the kernel.
func (r *router) cleanupFailedDNATAdd(rules map[string]ruleInfo) {
for key := range rules {
delete(r.rules, key)
}
if err := r.rollbackRules(rules); err != nil {
log.Errorf("rollback failed: %v", err)
}
}
func (r *router) rollbackRules(rules map[string]ruleInfo) error {
var merr *multierror.Error
for key, ruleInfo := range rules {
@@ -868,63 +1016,70 @@ func (r *router) rollbackRules(rules map[string]ruleInfo) error {
}
func (r *router) DeleteDNATRule(rule firewall.Rule) error {
if err := r.ipFwdState.ReleaseForwarding(); err != nil {
log.Errorf("%v", err)
}
ruleKey := rule.ID()
_, hadDNAT := r.rules[ruleKey+dnatSuffix]
_, hadSNAT := r.rules[ruleKey+snatSuffix]
_, hadFWD := r.rules[ruleKey+fwdSuffix]
if !hadDNAT && !hadSNAT && !hadFWD {
return nil
}
var merr *multierror.Error
if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists {
if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil {
merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err))
} else {
delete(r.rules, ruleKey+dnatSuffix)
}
delete(r.rules, ruleKey+dnatSuffix)
}
if snatRule, exists := r.rules[ruleKey+snatSuffix]; exists {
if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil {
merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err))
} else {
delete(r.rules, ruleKey+snatSuffix)
}
delete(r.rules, ruleKey+snatSuffix)
}
if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists {
if err := r.iptablesClient.Delete(tableFilter, chainRTFWDOUT, fwdRule...); err != nil {
merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err))
} else {
delete(r.rules, ruleKey+fwdSuffix)
}
}
// Release the refcount only once all rules are gone from the kernel. On
// partial failure the failed entries stay in r.rules so a retry can remove
// them and release then.
if merr == nil {
if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil {
log.Errorf("%v", err)
}
delete(r.rules, ruleKey+fwdSuffix)
}
r.updateState()
return nberrors.FormatErrorOrNil(merr)
}
func (r *router) genRouteRuleSpec(params routeFilteringRuleParams, sources []netip.Prefix) ([]string, error) {
// assembleRouteRule joins the pre-built source and destination matches with the
// protocol, ports and target of a route ACL.
func assembleRouteRule(sourceExp, destExp []string, params routeFilteringRuleParams, v6 bool) []string {
var rule []string
sourceExp, err := r.applyNetwork("-s", params.Source, sources)
if err != nil {
return nil, fmt.Errorf("apply network -s: %w", err)
}
destExp, err := r.applyNetwork("-d", params.Destination, nil)
if err != nil {
return nil, fmt.Errorf("apply network -d: %w", err)
}
rule = append(rule, sourceExp...)
rule = append(rule, destExp...)
if params.Proto != firewall.ProtocolALL {
rule = append(rule, "-p", strings.ToLower(protoForFamily(params.Proto, r.v6)))
rule = append(rule, "-p", strings.ToLower(protoForFamily(params.Proto, v6)))
rule = append(rule, applyPort("--sport", params.SPort)...)
rule = append(rule, applyPort("--dport", params.DPort)...)
}
rule = append(rule, "-j", actionToStr(params.Action))
return rule, nil
return rule
}
func (r *router) applyNetwork(flag string, network firewall.Network, prefixes []netip.Prefix) ([]string, error) {
@@ -934,9 +1089,17 @@ func (r *router) applyNetwork(flag string, network firewall.Network, prefixes []
}
if network.IsSet() {
// A destination set is populated later from DNS results, so unlike a
// source set it cannot be expanded into per-prefix rules here. Without
// ipset such a rule is not expressible; report it instead of installing
// something broader than the policy allows.
if flag == "-d" && !r.ipsetSupport.supported() {
return nil, fmt.Errorf("destination set %s requires ipset (ip_set_hash_net and xt_set)", network.Set.HashedName())
}
name := r.ipsetName(network.Set.HashedName())
if _, err := r.ipsetCounter.Increment(name, prefixes); err != nil {
return nil, fmt.Errorf("create or get ipset: %w", err)
return nil, ipsetUnusable(fmt.Errorf("create or get ipset: %w", err))
}
return []string{"-m", "set", matchSet, name, direction}, nil

View File

@@ -3,9 +3,11 @@
package iptables
import (
"errors"
"fmt"
"net/netip"
"os/exec"
"strings"
"testing"
"github.com/coreos/go-iptables/iptables"
@@ -15,7 +17,9 @@ import (
firewall "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/firewall/test"
"github.com/netbirdio/netbird/client/iface"
nbid "github.com/netbirdio/netbird/client/internal/acl/id"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/shared/management/domain"
)
func isIptablesSupported() bool {
@@ -31,7 +35,7 @@ func TestIptablesManager_RestoreOrCreateContainers(t *testing.T) {
iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
require.NoError(t, err, "failed to init iptables client")
manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU)
manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU, newIPSetSupport())
require.NoError(t, err, "should return a valid iptables manager")
require.NoError(t, manager.init(nil))
@@ -84,7 +88,7 @@ func TestIptablesManager_AddNatRule(t *testing.T) {
iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
require.NoError(t, err, "failed to init iptables client")
manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU)
manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU, newIPSetSupport())
require.NoError(t, err, "shouldn't return error")
require.NoError(t, manager.init(nil))
@@ -157,7 +161,7 @@ func TestIptablesManager_RemoveNatRule(t *testing.T) {
t.Run(testCase.Name, func(t *testing.T) {
iptablesClient, _ := iptables.NewWithProtocol(iptables.ProtocolIPv4)
manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU)
manager, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU, newIPSetSupport())
require.NoError(t, err, "shouldn't return error")
require.NoError(t, manager.init(nil))
defer func() {
@@ -219,7 +223,7 @@ func TestRouter_AddRouteFiltering(t *testing.T) {
iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
require.NoError(t, err, "Failed to create iptables client")
r, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU)
r, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU, newIPSetSupport())
require.NoError(t, err, "Failed to create router manager")
require.NoError(t, r.init(nil))
@@ -337,27 +341,26 @@ func TestRouter_AddRouteFiltering(t *testing.T) {
ruleKey, err := r.AddRouteFiltering(nil, tt.sources, firewall.Network{Prefix: tt.destination}, tt.proto, tt.sPort, tt.dPort, tt.action)
require.NoError(t, err, "AddRouteFiltering failed")
// Check if the rule is in the internal map
rule, ok := r.rules[ruleKey.ID()]
assert.True(t, ok, "Rule not found in internal map")
// A kernel without usable ipset splits a multi-source ACL into one
// rule per source, so compare against whichever form is in effect.
useIPSet := r.ipsetSupport.supported()
// Log the internal rule
t.Logf("Internal rule: %v", rule)
// Check if the rules are in the internal map
rules := routeRuleSpecs(t, r, ruleKey.ID())
require.NotEmpty(t, rules, "Rule not found in internal map")
// Check if the rule exists in iptables
exists, err := iptablesClient.Exists(tableFilter, chainRTFWDIN, rule...)
assert.NoError(t, err, "Failed to check rule existence")
assert.True(t, exists, "Rule not found in iptables")
// Log the internal rules
t.Logf("Internal rules: %v", rules)
var source firewall.Network
if len(tt.sources) > 1 {
source.Set = firewall.NewPrefixSet(tt.sources)
} else if len(tt.sources) > 0 {
source.Prefix = tt.sources[0]
// Check if the rules exist in iptables
for _, rule := range rules {
exists, err := iptablesClient.Exists(tableFilter, chainRTFWDIN, rule...)
assert.NoError(t, err, "Failed to check rule existence")
assert.True(t, exists, "Rule not found in iptables")
}
// Verify rule content
params := routeFilteringRuleParams{
Source: source,
Destination: firewall.Network{Prefix: tt.destination},
Proto: tt.proto,
SPort: tt.sPort,
@@ -365,20 +368,18 @@ func TestRouter_AddRouteFiltering(t *testing.T) {
Action: tt.action,
}
expectedRule, err := r.genRouteRuleSpec(params, nil)
expectedRules, err := r.genRouteRuleSpecs(params, tt.sources, useIPSet)
require.NoError(t, err, "Failed to generate expected rule spec")
if tt.expectSet {
if tt.expectSet && useIPSet {
setName := firewall.NewPrefixSet(tt.sources).HashedName()
expectedRule, err = r.genRouteRuleSpec(params, nil)
require.NoError(t, err, "Failed to generate expected rule spec with set")
// Check if the set was created
_, exists := r.ipsetCounter.Get(setName)
assert.True(t, exists, "IPSet not created")
}
assert.Equal(t, expectedRule, rule, "Rule content mismatch")
assert.Equal(t, expectedRules, rules, "Rule content mismatch")
// Clean up
err = r.DeleteRouteRule(ruleKey)
@@ -445,3 +446,145 @@ func TestFindSetNameInRule(t *testing.T) {
})
}
}
// TestRouter_AddRouteFilteringIPSetFallback covers a kernel that cannot use ipset:
// a multi-source route ACL must become one rule per source prefix, all present in
// the chain, and deleting the ACL must remove every one of them. Without the
// fallback the rule was never installed and the interface-wide DROP in FORWARD
// silently dropped routed traffic.
func TestRouter_AddRouteFilteringIPSetFallback(t *testing.T) {
if !isIptablesSupported() {
t.Skip("iptables not supported on this system")
}
iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
require.NoError(t, err)
support := newIPSetSupport()
support.markUnsupported(errors.New("test: pretend the kernel has no ipset"))
r, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU, support)
require.NoError(t, err)
require.NoError(t, r.init(nil))
t.Cleanup(func() {
require.NoError(t, r.Reset())
})
sources := []netip.Prefix{
netip.MustParsePrefix("172.16.0.0/16"),
netip.MustParsePrefix("192.168.0.0/16"),
}
destination := firewall.Network{Prefix: netip.MustParsePrefix("10.0.0.0/8")}
rule, err := r.AddRouteFiltering(nil, sources, destination, firewall.ProtocolTCP, nil,
&firewall.Port{Values: []uint16{443}}, firewall.ActionAccept)
require.NoError(t, err, "route ACL must install without ipset")
specs := routeRuleSpecs(t, r, rule.ID())
require.Len(t, specs, len(sources), "each source prefix needs its own rule")
for i, spec := range specs {
joined := strings.Join(spec, " ")
require.Contains(t, joined, "-s "+sources[i].String(), "rule must match the source prefix directly")
require.NotContains(t, joined, matchSet, "fallback rule must not reference a set")
exists, err := iptablesClient.Exists(tableFilter, chainRTFWDIN, spec...)
require.NoError(t, err)
require.True(t, exists, "rule %d must be present in %s", i, chainRTFWDIN)
}
require.NoError(t, r.DeleteRouteRule(rule))
for i, spec := range specs {
exists, err := iptablesClient.Exists(tableFilter, chainRTFWDIN, spec...)
require.NoError(t, err)
require.False(t, exists, "rule %d must be removed", i)
}
require.Empty(t, routeRuleSpecs(t, r, rule.ID()), "no rule may be left recorded")
}
// TestRouter_DestinationSetRequiresIPSet documents that a dynamic (domain)
// destination cannot be expressed without ipset: its prefixes are only known
// after DNS resolution, so there is nothing to expand into per-prefix rules. The
// call must report that rather than install a broader rule than the policy allows.
func TestRouter_DestinationSetRequiresIPSet(t *testing.T) {
if !isIptablesSupported() {
t.Skip("iptables not supported on this system")
}
iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
require.NoError(t, err)
support := newIPSetSupport()
support.markUnsupported(errors.New("test: pretend the kernel has no ipset"))
r, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU, support)
require.NoError(t, err)
require.NoError(t, r.init(nil))
t.Cleanup(func() {
require.NoError(t, r.Reset())
})
destination := firewall.Network{Set: firewall.NewDomainSet(domain.List{"example.com"})}
_, err = r.AddRouteFiltering(nil, []netip.Prefix{netip.MustParsePrefix("172.16.0.0/16")},
destination, firewall.ProtocolALL, nil, nil, firewall.ActionAccept)
require.Error(t, err, "a domain destination is not expressible without ipset")
require.ErrorContains(t, err, "requires ipset")
}
// TestRouter_RouteFilteringRollsBackPartialInstall covers a fallback ACL whose
// second rule cannot be installed. Nothing may be left behind: if the rule key
// survived, a later call would short-circuit on it and report success while some
// sources were never installed, leaving them unblocked for a drop rule.
func TestRouter_RouteFilteringRollsBackPartialInstall(t *testing.T) {
if !isIptablesSupported() {
t.Skip("iptables not supported on this system")
}
iptablesClient, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
require.NoError(t, err)
support := newIPSetSupport()
support.markUnsupported(errors.New("test: pretend the kernel has no ipset"))
r, err := newRouter(iptablesClient, ifaceMock, iface.DefaultMTU, support)
require.NoError(t, err)
require.NoError(t, r.init(nil))
t.Cleanup(func() {
require.NoError(t, r.Reset())
})
// The v6 prefix is rejected by the v4 iptables binary, so the second rule of
// the expansion fails after the first has been installed.
good := netip.MustParsePrefix("172.16.0.0/16")
sources := []netip.Prefix{good, netip.MustParsePrefix("2001:db8::/32")}
destination := firewall.Network{Prefix: netip.MustParsePrefix("10.0.0.0/8")}
_, err = r.AddRouteFiltering(nil, sources, destination, firewall.ProtocolALL, nil, nil, firewall.ActionDrop)
require.Error(t, err, "a source that iptables rejects must fail the whole ACL")
ruleKey := nbid.GenerateRouteRuleKey(sources, destination, firewall.ProtocolALL, nil, nil, firewall.ActionDrop)
require.Empty(t, routeRuleSpecs(t, r, string(ruleKey)), "no rule may stay recorded")
// The rule that did get installed must be gone from the chain.
installed := []string{"-s", good.String(), "-d", "10.0.0.0/8", "-j", "DROP"}
exists, err := iptablesClient.Exists(tableFilter, chainRTFWDIN, installed...)
require.NoError(t, err)
require.False(t, exists, "the already-installed rule must be rolled back")
}
// routeRuleSpecs collects the rules recorded for one route ACL, which is more than
// one when the ipset fallback splits it per source prefix.
func routeRuleSpecs(t *testing.T, r *router, ruleKey string) [][]string {
t.Helper()
var specs [][]string
for i := 0; ; i++ {
spec, exists := r.rules[routeRuleKey(ruleKey, i)]
if !exists {
return specs
}
specs = append(specs, spec)
}
}

View File

@@ -0,0 +1,249 @@
//go:build privileged
package nftables
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
fw "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/wgaddr"
)
func nftRefcountIfaceV4() *iFaceMock {
return &iFaceMock{
NameFunc: func() string { return "wt-refcount" },
AddressFunc: func() wgaddr.Address {
return wgaddr.Address{
IP: netip.MustParseAddr("100.96.0.1"),
Network: netip.MustParsePrefix("100.96.0.0/16"),
}
},
}
}
func nftRefcountIfaceDual() *iFaceMock {
return &iFaceMock{
NameFunc: func() string { return "wt-refcount" },
AddressFunc: func() wgaddr.Address {
return wgaddr.Address{
IP: netip.MustParseAddr("100.96.0.1"),
Network: netip.MustParsePrefix("100.96.0.0/16"),
IPv6: netip.MustParseAddr("fd00::1"),
IPv6Net: netip.MustParsePrefix("fd00::/64"),
}
},
}
}
func newNftRefcountManager(t *testing.T, dual bool) *Manager {
t.Helper()
if check() != NFTABLES {
t.Skip("nftables not supported on this system")
}
var ifMock *iFaceMock
if dual {
ifMock = nftRefcountIfaceDual()
} else {
ifMock = nftRefcountIfaceV4()
}
m, err := Create(ifMock, iface.DefaultMTU)
require.NoError(t, err, "create manager")
require.NoError(t, m.Init(nil), "init manager")
t.Cleanup(func() {
require.NoError(t, m.Close(nil), "close manager")
})
return m
}
func dnatV4(port uint16) fw.ForwardRule {
return fw.ForwardRule{
Protocol: fw.ProtocolTCP,
DestinationPort: fw.Port{Values: []uint16{port}},
TranslatedAddress: netip.MustParseAddr("100.96.0.2"),
TranslatedPort: fw.Port{Values: []uint16{80}},
}
}
func dnatV6(port uint16) fw.ForwardRule {
return fw.ForwardRule{
Protocol: fw.ProtocolTCP,
DestinationPort: fw.Port{Values: []uint16{port}},
TranslatedAddress: netip.MustParseAddr("fd00::2"),
TranslatedPort: fw.Port{Values: []uint16{80}},
}
}
// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the
// v4 refcount at zero.
func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) {
m := newNftRefcountManager(t, false)
state := m.router.ipFwdState
r1, err := m.AddDNATRule(dnatV4(8081))
require.NoError(t, err, "add v4 dnat 1")
v4, v6 := state.Counts()
assert.Equal(t, 1, v4, "v4 refcount after first add")
assert.Equal(t, 0, v6, "v6 refcount unchanged")
r2, err := m.AddDNATRule(dnatV4(8082))
require.NoError(t, err, "add v4 dnat 2")
v4, v6 = state.Counts()
assert.Equal(t, 2, v4, "v4 refcount after second add")
assert.Equal(t, 0, v6, "v6 refcount unchanged")
require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1")
v4, v6 = state.Counts()
assert.Equal(t, 1, v4, "v4 refcount after first delete")
assert.Equal(t, 0, v6, "v6 refcount unchanged")
require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2")
v4, v6 = state.Counts()
assert.Equal(t, 0, v4, "v4 refcount after second delete")
assert.Equal(t, 0, v6, "v6 refcount unchanged")
}
// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only
// and decrements back to zero on Delete.
func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) {
m := newNftRefcountManager(t, true)
require.NotNil(t, m.router6, "v6 router")
require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state")
state := m.router.ipFwdState
r1, err := m.AddDNATRule(dnatV6(9091))
require.NoError(t, err, "add v6 dnat 1")
v4, v6 := state.Counts()
assert.Equal(t, 0, v4, "v4 refcount unchanged")
assert.Equal(t, 1, v6, "v6 refcount after first add")
r2, err := m.AddDNATRule(dnatV6(9092))
require.NoError(t, err, "add v6 dnat 2")
v4, v6 = state.Counts()
assert.Equal(t, 0, v4)
assert.Equal(t, 2, v6, "v6 refcount after second add")
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1")
v4, v6 = state.Counts()
assert.Equal(t, 0, v4, "v4 refcount unchanged")
assert.Equal(t, 1, v6, "v6 refcount after first delete")
require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2")
v4, v6 = state.Counts()
assert.Equal(t, 0, v4)
assert.Equal(t, 0, v6, "v6 refcount after second delete")
}
// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same
// ForwardRule) does not double-increment the refcount.
func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) {
m := newNftRefcountManager(t, true)
state := m.router.ipFwdState
rule := dnatV4(8083)
r1, err := m.AddDNATRule(rule)
require.NoError(t, err, "add v4 dnat")
v4, _ := state.Counts()
assert.Equal(t, 1, v4)
// duplicate add: same rule ID, must be a no-op for the refcount.
_, err = m.AddDNATRule(rule)
require.NoError(t, err, "duplicate add")
v4, _ = state.Counts()
assert.Equal(t, 1, v4, "duplicate add must not increment")
require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat")
v4, _ = state.Counts()
assert.Equal(t, 0, v4, "single delete must drop to zero")
}
// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was
// never added does not underflow the refcount.
func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
m := newNftRefcountManager(t, true)
state := m.router.ipFwdState
// Construct a Rule reference for something never added. The router stores
// rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing
// entry must be a no-op rather than calling Release.
phantom := dnatV4(8099)
require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat")
v4, v6 := state.Counts()
assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete")
assert.Equal(t, 0, v6, "v6 refcount unaffected")
phantom6 := dnatV6(9099)
require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat")
v4, v6 = state.Counts()
assert.Equal(t, 0, v4)
assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete")
// And after a phantom delete, a real add still results in count=1.
r1, err := m.AddDNATRule(dnatV4(8100))
require.NoError(t, err, "add v4 dnat after phantom delete")
v4, _ = state.Counts()
assert.Equal(t, 1, v4, "real add still increments after phantom delete")
require.NoError(t, m.DeleteDNATRule(r1))
}
// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
// (called on every network-map update) holds at most one reference per family
// and a single DisableRouting drops both back to zero.
func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) {
m := newNftRefcountManager(t, true)
state := m.router.ipFwdState
require.NoError(t, m.EnableRouting(), "first enable")
require.NoError(t, m.EnableRouting(), "second enable")
require.NoError(t, m.EnableRouting(), "third enable")
v4, v6 := state.Counts()
assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
require.NoError(t, m.DisableRouting(), "disable")
v4, v6 = state.Counts()
assert.Equal(t, 0, v4, "single disable releases the v4 reference")
assert.Equal(t, 0, v6, "single disable releases the v6 reference")
}
// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired
// DisableRouting does not release references held by active DNAT rules.
func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) {
m := newNftRefcountManager(t, true)
state := m.router.ipFwdState
r1, err := m.AddDNATRule(dnatV6(9095))
require.NoError(t, err, "add v6 dnat")
require.NoError(t, m.DisableRouting(), "unpaired disable")
_, v6 := state.Counts()
assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
_, v6 = state.Counts()
assert.Equal(t, 0, v6, "delete releases the DNAT reference")
}
// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule
// twice does not underflow the refcount (the second delete is a no-op).
func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
m := newNftRefcountManager(t, true)
state := m.router.ipFwdState
r1, err := m.AddDNATRule(dnatV6(9093))
require.NoError(t, err)
_, v6 := state.Counts()
assert.Equal(t, 1, v6)
require.NoError(t, m.DeleteDNATRule(r1), "first delete")
_, v6 = state.Counts()
assert.Equal(t, 0, v6)
require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
_, v6 = state.Counts()
assert.Equal(t, 0, v6, "double delete must not underflow")
}

View File

@@ -105,8 +105,8 @@ func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mt
return fmt.Errorf("create v6 router: %w", err)
}
// Share the same IP forwarding state with the v4 router, since
// EnableIPForwarding controls both v4 and v6 sysctls.
// Share the per-family forwarding refcounter with the v4 router so a v4
// rule and a v6 rule against the same state machine cooperate cleanly.
m.router6.ipFwdState = m.router.ipFwdState
m.aclManager6, err = newAclManager(workTable6, wgIface, chainNameRoutingFw)
@@ -530,17 +530,12 @@ func (m *Manager) SetLogLevel(log.Level) {
}
func (m *Manager) EnableRouting() error {
if err := m.router.ipFwdState.RequestForwarding(); err != nil {
return fmt.Errorf("enable IP forwarding: %w", err)
}
return nil
// v6 only when the overlay actually has v6.
return m.router.ipFwdState.RequestRouting(m.router6 != nil)
}
func (m *Manager) DisableRouting() error {
if err := m.router.ipFwdState.ReleaseForwarding(); err != nil {
return fmt.Errorf("disable IP forwarding: %w", err)
}
return nil
return m.router.ipFwdState.ReleaseRouting()
}
// Flush rule/chain/set operations from the buffer

View File

@@ -93,7 +93,7 @@ func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*rou
rules: make(map[string]*nftables.Rule),
af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4),
wgIface: wgIface,
ipFwdState: ipfwdstate.NewIPForwardingState(),
ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()),
mtu: mtu,
}
@@ -1553,10 +1553,6 @@ func (r *router) refreshRulesMap() error {
}
func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
if err := r.ipFwdState.RequestForwarding(); err != nil {
return nil, err
}
ruleKey := rule.ID()
if _, exists := r.rules[ruleKey+dnatSuffix]; exists {
return rule, nil
@@ -1567,7 +1563,18 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
return nil, fmt.Errorf("convert protocol to number: %w", err)
}
// Request forwarding before queueing rules: addDnatRedirect/addDnatMasq
// buffer netlink messages on r.conn that the next caller's Flush would
// commit if we returned without flushing them ourselves.
v6 := r.af.tableFamily == nftables.TableFamilyIPv6
if err := r.ipFwdState.RequestForwarding(v6); err != nil {
return nil, fmt.Errorf("enable forwarding: %w", err)
}
if err := r.addDnatRedirect(rule, protoNum, ruleKey); err != nil {
if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil {
log.Warnf("rollback forwarding refcount: %v", rerr)
}
return nil, err
}
@@ -1579,6 +1586,11 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
// TODO: find chains with drop policies and add rules there
if err := r.conn.Flush(); err != nil {
if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil {
log.Warnf("rollback forwarding refcount: %v", rerr)
}
delete(r.rules, ruleKey+dnatSuffix)
delete(r.rules, ruleKey+snatSuffix)
return nil, fmt.Errorf("flush rules: %w", err)
}
@@ -1781,16 +1793,18 @@ func (r *router) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleKey
}
func (r *router) DeleteDNATRule(rule firewall.Rule) error {
if err := r.ipFwdState.ReleaseForwarding(); err != nil {
log.Errorf("%v", err)
}
ruleKey := rule.ID()
if err := r.refreshRulesMap(); err != nil {
return fmt.Errorf(refreshRulesMapError, err)
}
_, hadDNAT := r.rules[ruleKey+dnatSuffix]
_, hadSNAT := r.rules[ruleKey+snatSuffix]
if !hadDNAT && !hadSNAT {
return nil
}
var merr *multierror.Error
var needsFlush bool
@@ -1822,9 +1836,16 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error {
}
}
// Release the refcount only once the rules are gone from the kernel. On
// failure (including the refreshRulesMap error above) the rules and their
// map entries remain, keeping forwarding on until a retry removes them.
if merr == nil {
delete(r.rules, ruleKey+dnatSuffix)
delete(r.rules, ruleKey+snatSuffix)
if err := r.ipFwdState.ReleaseForwarding(r.af.tableFamily == nftables.TableFamilyIPv6); err != nil {
log.Errorf("%v", err)
}
}
return nberrors.FormatErrorOrNil(merr)

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
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)
}

View File

@@ -1,29 +0,0 @@
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)
})
}
}

View File

@@ -34,9 +34,8 @@ import (
"github.com/netbirdio/netbird/shared/netiputil"
)
const readmeContent = `Netbird debug bundle
This debug bundle contains the following files.
If the --anonymize flag is set, the files are anonymized to protect sensitive information.
const readmeContent = `This debug bundle contains the following files.
If anonymization is enabled (--anonymize / --anonymize-level), the files are anonymized to protect sensitive information.
status.txt: Anonymized status information of the NetBird client.
client.log: Most recent, anonymized client log file of the NetBird client.
@@ -70,21 +69,34 @@ capture.pcap: Packet capture in pcap format. Only present when capture was runni
Anonymization Process
The files in this bundle have been anonymized to protect sensitive information. Here's how the anonymization was applied:
The files in this bundle have been anonymized to protect sensitive information. The level applied to this bundle is recorded at the top of this file. Here's how the anonymization was applied:
IP Addresses
IPv4 addresses are replaced with addresses starting from 198.51.100.0
IPv6 addresses are replaced with addresses starting from 100::
Default level:
- Public IPv4 addresses are replaced with addresses starting from 198.51.100.0
- Public IPv6 addresses are replaced with addresses starting from 2001:db8:ffff::
- IPv6 unique local addresses (fc00::/7) are anonymized as well: their random global ID uniquely identifies the network.
- IP addresses from internal IPv4 ranges and well-known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., 169.254., fe80::).
Strict level (--anonymize-level strict), in addition to the default level:
- Private (RFC 1918), CGNAT (100.64.0.0/10), and link-local (169.254.0.0/16, fe80::/10) addresses are anonymized too.
- Internal IPv4 addresses are replaced with addresses starting from 198.18.0.0 and internal IPv6 addresses with addresses starting from 2001:db8:1::, so internal addresses remain distinguishable from public ones.
- Addresses are mapped in order of first appearance: subnet structure, allocation scheme, and gateway conventions are not preserved. Prefix lengths of networks are preserved.
- Peer names in front of NetBird domains are replaced with numbered placeholders (e.g. peer-1.netbird.cloud), and subdomain labels of other domains with host-N placeholders.
- WireGuard public keys are replaced with consistent placeholder keys.
IP addresses from non public ranges and well known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., etc.).
Reoccuring IP addresses are replaced with the same anonymized address.
Note: The anonymized IP addresses in the status file do not match those in the log and routes files. However, the anonymized IP addresses are consistent within the status file and across the routes and log files.
MAC Addresses
MAC addresses are replaced at every anonymization level with consistent placeholders counting up from 02:00:00:00:00:01. Broadcast, multicast, and all-zero addresses are kept. At the default level a preserved IPv6 link-local address may still embed a MAC address (EUI-64); the strict level anonymizes those addresses.
Domains
All domain names (except for the netbird domains) are replaced with randomly generated strings ending in ".domain". Anonymized domains are consistent across all files in the bundle.
Reoccuring domain names are replaced with the same anonymized domain.
At the strict level, the peer name labels in front of netbird domains are anonymized as well.
Sync Response
The network_map.json file contains the following anonymized information:
@@ -281,6 +293,7 @@ type BundleGenerator struct {
cliVersion string
anonymize bool
anonymizeLevel anonymize.Level
includeSystemInfo bool
logFileCount uint32
@@ -288,7 +301,10 @@ type BundleGenerator struct {
}
type BundleConfig struct {
Anonymize bool
Anonymize bool
// AnonymizeLevel selects how much the anonymizer redacts.
// anonymize.LevelStrict implies Anonymize.
AnonymizeLevel anonymize.Level
IncludeSystemInfo bool
LogFileCount uint32
}
@@ -327,8 +343,11 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
uiLogOpener = openLogFile
}
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
anonymizer.SetLevel(cfg.AnonymizeLevel)
return &BundleGenerator{
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
anonymizer: anonymizer,
internalConfig: deps.InternalConfig,
statusRecorder: deps.StatusRecorder,
@@ -345,7 +364,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
daemonVersion: deps.DaemonVersion,
cliVersion: deps.CliVersion,
anonymize: cfg.Anonymize,
anonymize: cfg.Anonymize || cfg.AnonymizeLevel >= anonymize.LevelStrict,
anonymizeLevel: cfg.AnonymizeLevel,
includeSystemInfo: cfg.IncludeSystemInfo,
logFileCount: logFileCount,
}
@@ -485,7 +505,13 @@ func (g *BundleGenerator) addSystemInfo() {
}
func (g *BundleGenerator) addReadme() error {
readmeReader := strings.NewReader(readmeContent)
level := "none (anonymization disabled)"
if g.anonymize {
level = g.anonymizeLevel.String()
}
header := fmt.Sprintf("Netbird debug bundle\nAnonymization level applied to this bundle: %s\n", level)
readmeReader := strings.NewReader(header + readmeContent)
if err := g.addFileToZip(readmeReader, "README.txt"); err != nil {
return fmt.Errorf("add README file to zip: %w", err)
}
@@ -507,9 +533,10 @@ func (g *BundleGenerator) addStatus() error {
fullStatus := g.statusRecorder.GetFullStatus()
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
Anonymize: g.anonymize,
ProfileName: profName,
DaemonVersion: g.daemonVersion,
Anonymize: g.anonymize,
AnonymizeLevel: g.anonymizeLevel,
ProfileName: profName,
DaemonVersion: g.daemonVersion,
})
overview.CliVersion = g.cliVersion
statusOutput := overview.FullDetailSummary()
@@ -662,7 +689,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString("NetBird Client Configuration:\n\n")
if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil {
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", key.PublicKey().String()))
configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", g.anonymizer.AnonymizeWGKey(key.PublicKey().String())))
}
configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface))
configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort))
@@ -952,6 +979,11 @@ func (g *BundleGenerator) addUpdateLogs() error {
}
baseName := filepath.Base(logFile)
data, err = g.anonymizeBytes(data)
if err != nil {
log.Warnf("skipping update log file %s: %v", baseName, err)
continue
}
if err := g.addFileToZip(bytes.NewReader(data), filepath.Join("update-logs", baseName)); err != nil {
return fmt.Errorf("add update log file %s to zip: %w", baseName, err)
}
@@ -979,6 +1011,13 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
}
fileName := filepath.Base(match)
// Corrupted state files usually fail structured JSON anonymization,
// so run them through the string anonymizer instead.
data, err = g.anonymizeBytes(data)
if err != nil {
log.Warnf("skipping corrupted state file %s: %v", fileName, err)
continue
}
if err := g.addFileToZip(bytes.NewReader(data), "corrupted_states/"+fileName); err != nil {
log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err)
continue
@@ -990,6 +1029,27 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
return nil
}
// anonymizeBytes runs raw file content through the string anonymizer line by
// line when anonymization is enabled. It errors instead of returning partial
// content, so a caller never adds an unanonymized fallback to the bundle.
func (g *BundleGenerator) anonymizeBytes(data []byte) ([]byte, error) {
if !g.anonymize {
return data, nil
}
var buf bytes.Buffer
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
buf.WriteString(g.anonymizer.AnonymizeString(scanner.Text()))
buf.WriteByte('\n')
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("anonymize content: %w", err)
}
return buf.Bytes(), nil
}
func (g *BundleGenerator) addMetrics() error {
if g.clientMetrics == nil {
log.Debugf("skipping metrics in debug bundle: no metrics collector")
@@ -1462,6 +1522,7 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize.
}
peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn)
peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey)
anonymizeSSHConfig(peer.SshConfig)
}

View File

@@ -844,6 +844,10 @@ func collectSysctls() string {
[]string{"net.ipv4.conf.all.src_valid_mark", "net.ipv4.conf.default.src_valid_mark"},
listInterfaceSysctls("ipv4", "src_valid_mark")...,
))
writeSysctlGroup(&builder, "accept_ra", append(
[]string{"net.ipv6.conf.all.accept_ra", "net.ipv6.conf.default.accept_ra"},
listInterfaceSysctls("ipv6", "accept_ra")...,
))
writeSysctlGroup(&builder, "conntrack", []string{
"net.netfilter.nf_conntrack_acct",
"net.netfilter.nf_conntrack_tcp_loose",

View File

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

View File

@@ -267,18 +267,38 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) {
return SystemDNSSettings{}, fmt.Errorf("sending the command: %w", err)
}
var dnsSettings SystemDNSSettings
dnsSettings, serverAddresses, err := parseSystemDNSSettings(b)
if err != nil {
return dnsSettings, err
}
s.mu.Lock()
s.origNameservers = serverAddresses
s.mu.Unlock()
return dnsSettings, nil
}
// parseSystemDNSSettings parses the output of `scutil show State:/Network/Service/<id>/DNS`.
// Lines that don't match the expected "index : value" shape are skipped: hosts with unusual
// network services (e.g. orphaned hardware ports) can produce entries without a value.
func parseSystemDNSSettings(out []byte) (SystemDNSSettings, []netip.Addr, error) {
// port is not exposed by scutil, default to 53
dnsSettings := SystemDNSSettings{ServerPort: DefaultPort}
var serverAddresses []netip.Addr
inSearchDomainsArray := false
inServerAddressesArray := false
scanner := bufio.NewScanner(bytes.NewReader(b))
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
switch {
case strings.HasPrefix(line, "DomainName :"):
domainName := strings.TrimSpace(strings.Split(line, ":")[1])
dnsSettings.Domains = append(dnsSettings.Domains, domainName)
domainName := strings.TrimSpace(strings.TrimPrefix(line, "DomainName :"))
if domainName != "" {
dnsSettings.Domains = append(dnsSettings.Domains, domainName)
}
continue
case line == "SearchDomains : <array> {":
inSearchDomainsArray = true
continue
@@ -288,36 +308,45 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) {
case line == "}":
inSearchDomainsArray = false
inServerAddressesArray = false
continue
}
if !inSearchDomainsArray && !inServerAddressesArray {
continue
}
parts := strings.SplitN(line, " : ", 2)
if len(parts) != 2 {
log.Debugf("skipping unexpected scutil DNS line %q", line)
continue
}
value := strings.TrimSpace(parts[1])
if value == "" {
continue
}
if inSearchDomainsArray {
searchDomain := strings.Split(line, " : ")[1]
dnsSettings.Domains = append(dnsSettings.Domains, searchDomain)
} else if inServerAddressesArray {
address := strings.Split(line, " : ")[1]
if ip, err := netip.ParseAddr(address); err == nil && !ip.IsUnspecified() {
ip = ip.Unmap()
serverAddresses = append(serverAddresses, ip)
// Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4.
if !dnsSettings.ServerIP.IsValid() && ip.Is4() {
dnsSettings.ServerIP = ip
}
}
dnsSettings.Domains = append(dnsSettings.Domains, value)
continue
}
ip, err := netip.ParseAddr(value)
if err != nil || ip.IsUnspecified() {
continue
}
ip = ip.Unmap()
serverAddresses = append(serverAddresses, ip)
// Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4.
if !dnsSettings.ServerIP.IsValid() && ip.Is4() {
dnsSettings.ServerIP = ip
}
}
if err := scanner.Err(); err != nil {
return dnsSettings, err
return dnsSettings, serverAddresses, err
}
// default to 53 port
dnsSettings.ServerPort = DefaultPort
s.mu.Lock()
s.origNameservers = serverAddresses
s.mu.Unlock()
return dnsSettings, nil
return dnsSettings, serverAddresses, nil
}
func (s *systemConfigurator) getOriginalNameservers() []netip.Addr {
@@ -435,11 +464,15 @@ func (s *systemConfigurator) getPrimaryService() (string, string, error) {
router := ""
for scanner.Scan() {
text := scanner.Text()
parts := strings.SplitN(text, ":", 2)
if len(parts) != 2 {
continue
}
if strings.Contains(text, "PrimaryService") {
primaryService = strings.TrimSpace(strings.Split(text, ":")[1])
primaryService = strings.TrimSpace(parts[1])
}
if strings.Contains(text, "Router") {
router = strings.TrimSpace(strings.Split(text, ":")[1])
router = strings.TrimSpace(parts[1])
}
}
if err := scanner.Err(); err != nil && err != io.EOF {

View File

@@ -328,6 +328,120 @@ func removeTestDNSKey(key string) error {
return err
}
func TestParseSystemDNSSettings(t *testing.T) {
tests := []struct {
name string
output string
expectedDomains []string
expectedServers []netip.Addr
expectedIP netip.Addr
}{
{
name: "well_formed",
output: `<dictionary> {
DomainName : example.com
SearchDomains : <array> {
0 : example.com
1 : corp.example.com
}
ServerAddresses : <array> {
0 : 192.168.1.1
1 : fd00::53
}
}
`,
expectedDomains: []string{"example.com", "example.com", "corp.example.com"},
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1"), netip.MustParseAddr("fd00::53")},
expectedIP: netip.MustParseAddr("192.168.1.1"),
},
{
// entries without a value after the separator used to panic with
// "index out of range [1] with length 1"
name: "malformed_array_entries_skipped",
output: `<dictionary> {
SearchDomains : <array> {
0 :
(null)
1 : corp.example.com
}
ServerAddresses : <array> {
0 :
1 : 192.168.1.1
}
}
`,
expectedDomains: []string{"corp.example.com"},
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
expectedIP: netip.MustParseAddr("192.168.1.1"),
},
{
name: "domain_name_without_value_skipped",
output: `<dictionary> {
DomainName :
ServerAddresses : <array> {
0 : 192.168.1.1
}
}
`,
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
expectedIP: netip.MustParseAddr("192.168.1.1"),
},
{
name: "ipv6_first_prefers_ipv4_server_ip",
output: `<dictionary> {
ServerAddresses : <array> {
0 : fd00::53
1 : 192.168.1.1
}
}
`,
expectedServers: []netip.Addr{netip.MustParseAddr("fd00::53"), netip.MustParseAddr("192.168.1.1")},
expectedIP: netip.MustParseAddr("192.168.1.1"),
},
{
name: "invalid_and_unspecified_addresses_skipped",
output: `<dictionary> {
ServerAddresses : <array> {
0 : (null)
1 : 0.0.0.0
2 : 192.168.1.1
}
}
`,
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
expectedIP: netip.MustParseAddr("192.168.1.1"),
},
{
name: "v4_mapped_address_unmapped",
output: `<dictionary> {
ServerAddresses : <array> {
0 : ::ffff:192.168.1.1
}
}
`,
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
expectedIP: netip.MustParseAddr("192.168.1.1"),
},
{
name: "empty_output",
output: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
settings, servers, err := parseSystemDNSSettings([]byte(tc.output))
require.NoError(t, err, "parsing should not fail")
assert.Equal(t, tc.expectedDomains, settings.Domains, "domains should match")
assert.Equal(t, tc.expectedServers, servers, "server addresses should match")
assert.Equal(t, tc.expectedIP, settings.ServerIP, "server IP should match")
assert.Equal(t, DefaultPort, settings.ServerPort, "server port should default to 53")
})
}
}
func TestGetOriginalNameservers(t *testing.T) {
configurator := &systemConfigurator{
createdKeys: make(map[string]struct{}),

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
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)
}
}

View File

@@ -1,359 +0,0 @@
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]
}

View File

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

View File

@@ -1,117 +0,0 @@
//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
}

View File

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

View File

@@ -1,19 +0,0 @@
//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
}

View File

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

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
//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}

View File

@@ -1,146 +0,0 @@
//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
}

View File

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

View File

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

View File

@@ -1,126 +0,0 @@
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),
},
}
}

View File

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

View File

@@ -91,12 +91,6 @@ 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 {
@@ -106,16 +100,6 @@ 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

View File

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

View File

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

View File

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

View File

@@ -307,6 +307,8 @@ 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 {
@@ -959,12 +961,9 @@ func (conn *Conn) recordConnectionMetrics() {
priority := conn.currentConnPriority
conn.mu.Unlock()
var connType metrics.ConnectionType
switch priority {
case conntype.Relay:
connType = metrics.ConnectionTypeRelay
default:
connType = metrics.ConnectionTypeICE
connType := metricsConnType(priority)
if connType == metrics.ConnectionTypeUnknown {
return
}
// Record metrics with timestamps - duration calculation happens in metrics package
@@ -1065,3 +1064,16 @@ 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
}
}

View File

@@ -11,6 +11,8 @@ 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"
@@ -386,3 +388,33 @@ 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)
}
}

View File

@@ -2,54 +2,183 @@ package ipfwdstate
import (
"fmt"
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
)
// IPForwardingState is a struct that keeps track of the IP forwarding state.
// todo: read initial state of the IP forwarding from the system and reset the state based on it.
// todo: separate v4/v6 forwarding state, since the sysctls are independent
// (net.ipv4.ip_forward vs net.ipv6.conf.all.forwarding). Currently the nftables
// manager shares one instance between both routers, which works only because
// EnableIPForwarding enables both sysctls in a single call.
// IPForwardingState tracks v4 and v6 IP-forwarding sysctl enables with
// independent refcounts so a v4-only routing setup doesn't flip v6 sysctls.
type IPForwardingState struct {
enabledCounter int
mu sync.Mutex
v4Count int
v6Count int
// routingV4/routingV6 track whether the routing path currently holds a
// reference, so repeated EnableRouting calls (one per network-map update)
// hold at most one reference per family and an unpaired DisableRouting
// can't release references held by DNAT rules.
routingV4 bool
routingV6 bool
wgIfaceName string
v6Saved map[string]int
}
func NewIPForwardingState() *IPForwardingState {
return &IPForwardingState{}
// NewIPForwardingState returns a state tracker for the IP-forwarding sysctls.
// wgIfaceName is excluded from the per-interface accept_ra handling.
func NewIPForwardingState(wgIfaceName string) *IPForwardingState {
return &IPForwardingState{wgIfaceName: wgIfaceName}
}
func (f *IPForwardingState) RequestForwarding() error {
if f.enabledCounter != 0 {
f.enabledCounter++
// Counts returns the current v4 and v6 refcounts. Intended for diagnostics
// and tests.
func (f *IPForwardingState) Counts() (v4, v6 int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.v4Count, f.v6Count
}
// RequestRouting takes the forwarding references for the routing path. It is
// idempotent: while routing already holds a reference, further calls don't
// increment the refcounts, and a v4-only request releases a previously held v6
// reference. A v6 sysctl failure is logged and not returned so it can't take
// down v4 routing (the sysctl may be unwritable, e.g. read-only /proc/sys or
// IPv6 disabled on the kernel command line); v6 is retried on the next call.
func (f *IPForwardingState) RequestRouting(v6 bool) error {
f.mu.Lock()
defer f.mu.Unlock()
if !f.routingV4 {
if err := f.requestV4(); err != nil {
return err
}
f.routingV4 = true
}
if !v6 {
if !f.routingV6 {
return nil
}
f.routingV6 = false
return f.releaseV6()
}
if f.routingV6 {
return nil
}
if err := systemops.EnableIPForwarding(); err != nil {
return fmt.Errorf("failed to enable IP forwarding with sysctl: %w", err)
if err := f.requestV6(); err != nil {
log.Warnf("enable IPv6 forwarding for routing: %v", err)
return nil
}
f.enabledCounter = 1
log.Info("IP forwarding enabled")
f.routingV6 = true
return nil
}
func (f *IPForwardingState) ReleaseForwarding() error {
if f.enabledCounter == 0 {
return nil
// ReleaseRouting releases the references RequestRouting holds. Calls without a
// held reference are no-ops.
func (f *IPForwardingState) ReleaseRouting() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.routingV4 {
f.routingV4 = false
f.releaseV4()
}
if f.enabledCounter > 1 {
f.enabledCounter--
return nil
if f.routingV6 {
f.routingV6 = false
return f.releaseV6()
}
// if failed to disable IP forwarding we anyway decrement the counter
f.enabledCounter = 0
// todo call systemops.DisableIPForwarding()
return nil
}
// RequestForwarding enables the family's forwarding sysctl on first request.
func (f *IPForwardingState) RequestForwarding(v6 bool) error {
f.mu.Lock()
defer f.mu.Unlock()
if v6 {
return f.requestV6()
}
return f.requestV4()
}
// ReleaseForwarding decrements the family counter. The last v6 release restores
// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other
// tooling (docker, k8s, libvirt).
func (f *IPForwardingState) ReleaseForwarding(v6 bool) error {
f.mu.Lock()
defer f.mu.Unlock()
if v6 {
return f.releaseV6()
}
f.releaseV4()
return nil
}
func (f *IPForwardingState) requestV4() error {
if f.v4Count == 0 {
if err := systemops.EnableV4IPForwarding(); err != nil {
return fmt.Errorf("enable IPv4 forwarding: %w", err)
}
log.Info("IPv4 forwarding enabled")
}
f.v4Count++
return nil
}
func (f *IPForwardingState) releaseV4() {
if f.v4Count > 0 {
f.v4Count--
}
}
func (f *IPForwardingState) requestV6() error {
if f.v6Count == 0 {
saved, err := systemops.EnableV6IPForwarding(f.wgIfaceName)
if err != nil {
if rerr := systemops.DisableV6IPForwarding(saved); rerr != nil {
log.Warnf("rollback partial v6 sysctls: %v", rerr)
}
return fmt.Errorf("enable IPv6 forwarding: %w", err)
}
// A failed restore on a previous release keeps its saved values; those
// are the true originals, so keep them over what this enable captured.
if f.v6Saved == nil {
f.v6Saved = saved
} else {
for k, v := range saved {
if _, ok := f.v6Saved[k]; !ok {
f.v6Saved[k] = v
}
}
}
log.Info("IPv6 forwarding enabled")
}
f.v6Count++
return nil
}
func (f *IPForwardingState) releaseV6() error {
if f.v6Count == 0 {
return nil
}
f.v6Count--
if f.v6Count > 0 {
return nil
}
// Keep the saved values on failure so a later release or enable/release
// cycle can still restore them; re-restoring an already-restored key is a
// no-op since the sysctl already holds the desired value.
if err := systemops.DisableV6IPForwarding(f.v6Saved); err != nil {
return fmt.Errorf("disable IPv6 forwarding: %w", err)
}
f.v6Saved = nil
log.Info("IPv6 forwarding disabled")
return nil
}

View File

@@ -0,0 +1,39 @@
//go:build privileged
package ipfwdstate
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request
// releases a previously held routing-owned v6 reference without touching
// references held by DNAT rules.
func TestRequestRoutingV6ToV4Transition(t *testing.T) {
f := NewIPForwardingState("wt-fwd-test")
require.NoError(t, f.RequestRouting(true), "request routing with v6")
v4, v6 := f.Counts()
assert.Equal(t, 1, v4, "v4 reference held")
assert.Equal(t, 1, v6, "v6 reference held")
require.NoError(t, f.RequestRouting(false), "request routing v4-only")
v4, v6 = f.Counts()
assert.Equal(t, 1, v4, "v4 reference kept")
assert.Equal(t, 0, v6, "routing-owned v6 reference released")
// A DNAT-held reference survives a v4-only routing request.
require.NoError(t, f.RequestForwarding(true), "dnat v6 reference")
require.NoError(t, f.RequestRouting(false), "repeat v4-only request")
_, v6 = f.Counts()
assert.Equal(t, 1, v6, "dnat-held v6 reference survives")
require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference")
require.NoError(t, f.ReleaseRouting(), "release routing")
v4, v6 = f.Counts()
assert.Equal(t, 0, v4, "all v4 references released")
assert.Equal(t, 0, v6, "all v6 references released")
}

View File

@@ -58,11 +58,7 @@ func Setup(wgIface iface) (map[string]int, error) {
continue
}
// Escape '%' and '.' so they survive the dot-to-slash conversion in Set()
safeName := strings.ReplaceAll(intf.Name, "%", percentEscape)
safeName = strings.ReplaceAll(safeName, ".", dotEscape)
i := fmt.Sprintf(rpFilterInterfacePath, safeName)
i := fmt.Sprintf(rpFilterInterfacePath, EscapeInterfaceName(intf.Name))
oldVal, err := Set(i, 2, true)
if err != nil {
result = multierror.Append(result, err)
@@ -74,6 +70,13 @@ func Setup(wgIface iface) (map[string]int, error) {
return keys, nberrors.FormatErrorOrNil(result)
}
// EscapeInterfaceName escapes '%' and '.' in an interface name (e.g. VLANs
// like eth0.100) so the name survives the dot-to-slash conversion in Set.
func EscapeInterfaceName(name string) string {
safe := strings.ReplaceAll(name, "%", percentEscape)
return strings.ReplaceAll(safe, ".", dotEscape)
}
// Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1
func Set(key string, desiredValue int, onlyIfOne bool) (int, error) {
path := strings.ReplaceAll(key, ".", "/")

View File

@@ -32,8 +32,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error {
return nil
}
func EnableIPForwarding() error {
log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
func EnableV4IPForwarding() error {
log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
return nil
}
func EnableV6IPForwarding(string) (map[string]int, error) {
log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
return map[string]int{}, nil
}
func DisableV6IPForwarding(map[string]int) error {
return nil
}

View File

@@ -58,8 +58,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error {
return nil
}
func EnableIPForwarding() error {
log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
func EnableV4IPForwarding() error {
log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
return nil
}
func EnableV6IPForwarding(string) (map[string]int, error) {
log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
return map[string]int{}, nil
}
func DisableV6IPForwarding(map[string]int) error {
return nil
}

View File

@@ -763,13 +763,10 @@ func flushRoutes(tableID, family int) error {
return nberrors.FormatErrorOrNil(result)
}
func EnableIPForwarding() error {
func EnableV4IPForwarding() error {
if _, err := sysctl.Set(ipv4ForwardingPath, 1, false); err != nil {
return err
}
if _, err := sysctl.Set(ipv6ForwardingPath, 1, false); err != nil {
log.Warnf("failed to enable IPv6 forwarding: %v", err)
}
return nil
}

View File

@@ -43,8 +43,17 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error
return r.genericRemoveVPNRoute(prefix, intf)
}
func EnableIPForwarding() error {
log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
func EnableV4IPForwarding() error {
log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
return nil
}
func EnableV6IPForwarding(string) (map[string]int, error) {
log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
return map[string]int{}, nil
}
func DisableV6IPForwarding(map[string]int) error {
return nil
}

View File

@@ -0,0 +1,92 @@
//go:build !android
package systemops
import (
"fmt"
"net"
"os"
"github.com/hashicorp/go-multierror"
log "github.com/sirupsen/logrus"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/internal/routemanager/sysctl"
)
const (
// 1 (default) accepts RAs only while forwarding is off; 2 keeps RA
// acceptance on regardless, so RA-installed host defaults survive our
// v6 forwarding flip.
acceptRAInterfacePath = "net.ipv6.conf.%s.accept_ra"
acceptRADefaultPath = "net.ipv6.conf.default.accept_ra"
acceptRAProcPathFormat = "/proc/sys/net/ipv6/conf/%s/accept_ra"
)
// EnableV6IPForwarding bumps accept_ra=2 on host v6 interfaces before flipping
// forwarding=1, so RA-installed host defaults survive. Returns the prior values
// of sysctls we actually changed; entries already at the target are omitted.
func EnableV6IPForwarding(wgIfaceName string) (map[string]int, error) {
saved := map[string]int{}
bumpAcceptRA(saved, wgIfaceName)
oldVal, err := sysctl.Set(ipv6ForwardingPath, 1, false)
if err != nil {
return saved, err
}
if oldVal != 1 {
saved[ipv6ForwardingPath] = oldVal
}
return saved, nil
}
// DisableV6IPForwarding restores what EnableV6IPForwarding captured.
func DisableV6IPForwarding(saved map[string]int) error {
var result *multierror.Error
for key, value := range saved {
if _, err := sysctl.Set(key, value, false); err != nil {
result = multierror.Append(result, fmt.Errorf("restore %s: %w", key, err))
}
}
return nberrors.FormatErrorOrNil(result)
}
func bumpAcceptRA(saved map[string]int, wgIfaceName string) {
// Also bump conf.default so interfaces created while forwarding is on
// (hotplug, new Wi-Fi/dock) inherit accept_ra=2 and keep accepting RAs.
bumpAcceptRAKey(saved, acceptRADefaultPath)
interfaces, err := net.Interfaces()
if err != nil {
log.Warnf("list interfaces for accept_ra: %v", err)
return
}
for _, intf := range interfaces {
if intf.Name == "lo" || intf.Name == wgIfaceName {
continue
}
bumpAcceptRAForInterface(saved, intf.Name)
}
}
func bumpAcceptRAForInterface(saved map[string]int, name string) {
// Build procfs path from name, not the dotted key: VLAN names like eth0.100.
if _, err := os.Stat(fmt.Sprintf(acceptRAProcPathFormat, name)); err != nil {
return
}
bumpAcceptRAKey(saved, fmt.Sprintf(acceptRAInterfacePath, sysctl.EscapeInterfaceName(name)))
}
func bumpAcceptRAKey(saved map[string]int, key string) {
// onlyIfOne=true: leave admin overrides (0, 2) alone.
oldVal, err := sysctl.Set(key, 2, true)
if err != nil {
log.Warnf("bump %s: %v", key, err)
return
}
// With onlyIfOne, a write only happened when the old value was 1; values
// left untouched (0, 2) must not be recorded for restore.
if oldVal == 1 {
saved[key] = oldVal
}
}

View File

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

View File

@@ -2781,6 +2781,11 @@ type DebugBundleRequest struct {
// untrusted TLS certificate. Restricted to privileged callers; for
// self-hosted upload servers.
UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"`
// anonymizeLevel selects how much the anonymizer redacts: "default"
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
// Unknown values are treated as "strict". Only meaningful with anonymize;
// "strict" implies it.
AnonymizeLevel string `protobuf:"bytes,8,opt,name=anonymizeLevel,proto3" json:"anonymizeLevel,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2857,6 +2862,13 @@ func (x *DebugBundleRequest) GetUploadInsecure() bool {
return false
}
func (x *DebugBundleRequest) GetAnonymizeLevel() string {
if x != nil {
return x.AnonymizeLevel
}
return ""
}
type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -7253,7 +7265,7 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" +
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xdc\x01\n" +
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
@@ -7264,7 +7276,8 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"cliVersion\x18\x06 \x01(\tR\n" +
"cliVersion\x12&\n" +
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\"}\n" +
"\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\x12&\n" +
"\x0eanonymizeLevel\x18\b \x01(\tR\x0eanonymizeLevel\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +

View File

@@ -540,6 +540,11 @@ message DebugBundleRequest {
// untrusted TLS certificate. Restricted to privileged callers; for
// self-hosted upload servers.
bool uploadInsecure = 7;
// anonymizeLevel selects how much the anonymizer redacts: "default"
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
// Unknown values are treated as "strict". Only meaningful with anonymize;
// "strict" implies it.
string anonymizeLevel = 8;
}
message DebugBundleResponse {

View File

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

View File

@@ -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 := s.parseUsername(localUser.Username)
username, domain := 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 := s.parseUsername(localUser.Username)
username, domain := parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
req := PtyExecutionRequest{

View File

@@ -133,7 +133,12 @@ func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, resu
return nil
}
if result.User != nil && isPrivilegedUsername(result.User.Username) {
// 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" {
return nil
}

View File

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

View File

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

View File

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

View File

@@ -239,6 +239,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType string
port uint32
username string
uid string
expectError bool
errorMsg string
skipOnWindows bool
@@ -248,6 +249,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 80,
username: "testuser",
uid: "1000",
expectError: true,
errorMsg: "cannot bind to privileged port",
skipOnWindows: true,
@@ -257,6 +259,7 @@ 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,
@@ -266,6 +269,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 8080,
username: "testuser",
uid: "1000",
expectError: false,
},
{
@@ -273,6 +277,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
forwardType: "remote",
port: 0,
username: "testuser",
uid: "1000",
expectError: false,
},
{
@@ -280,13 +285,35 @@ 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,
},
}
@@ -299,7 +326,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
result := PrivilegeCheckResult{
Allowed: true,
User: &user.User{Username: tt.username},
User: &user.User{Username: tt.username, Uid: tt.uid},
}
err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result)
@@ -420,6 +447,13 @@ 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
@@ -440,44 +474,16 @@ func TestServer_IsPrivilegedUser(t *testing.T) {
expected: false,
description: "empty username should not be privileged",
},
}
// 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",
},
}...)
{
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 := isPrivilegedUsername(tt.username)
result := isPrivilegedOrUnknown(tt.username)
assert.Equal(t, tt.expected, result, tt.description)
})
}

View File

@@ -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 := s.parseUsername(targetUser.Username)
username, domain := parseUsername(targetUser.Username)
netbirdPath, err := os.Executable()
if err != nil {

View File

@@ -16,11 +16,6 @@ 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
@@ -29,6 +24,9 @@ var (
getIsProcessPrivileged = isCurrentProcessPrivileged
getEuid = os.Geteuid
getProcessElevated = isProcessElevated
getWindowsAccountPrivilegedOrUnknown = isWindowsAccountPrivilegedOrUnknown
)
const (
@@ -65,6 +63,13 @@ 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 {
@@ -75,7 +80,7 @@ func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult
// Handle empty username case - but still check root access controls
if req.RequestedUsername == "" {
if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot {
if isPrivilegedOrUnknown(context.currentUser.Username) && !context.allowRoot {
return PrivilegeCheckResult{
Allowed: false,
Error: &PrivilegedUserError{Username: context.currentUser.Username},
@@ -135,7 +140,7 @@ func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheck
needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser)
if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot {
if isPrivilegedOrUnknown(resolvedUser.Username) && !ctx.allowRoot {
return PrivilegeCheckResult{
Allowed: false,
Error: &PrivilegedUserError{Username: resolvedUser.Username},
@@ -175,6 +180,42 @@ 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 {
@@ -183,13 +224,6 @@ 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
@@ -253,159 +287,30 @@ func isWindowsSameUser(requestedUsername, currentUsername string) bool {
return strings.EqualFold(reqDomain, curDomain)
}
// 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 {
// 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 {
if getCurrentOS() != "windows" {
return username == "root"
}
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
return getWindowsAccountPrivilegedOrUnknown(username)
}
// 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 running as Administrator or SYSTEM.
// On Windows, this means the process token is elevated (administrators, SYSTEM).
func isCurrentProcessPrivileged() bool {
if getCurrentOS() == "windows" {
return isWindowsElevated()
return getProcessElevated()
}
return getEuid() == 0
}
// isWindowsElevated checks if the current process is running with elevated privileges on Windows
func isWindowsElevated() bool {
currentUser, err := getCurrentUser()
if err != nil {
log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err)
return false
}
if isWindowsPrivilegedSID(currentUser.Uid) {
log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid)
return true
}
if isPrivilegedUsername(currentUser.Username) {
log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username)
return true
}
log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid)
return false
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"os/user"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -27,8 +28,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
originalLookupUser := lookupUser
originalGetCurrentOS := getCurrentOS
originalGetEuid := getEuid
// Reset caches to ensure clean test state
originalGetProcessElevated := getProcessElevated
originalGetWindowsAccountPrivilegedOrUnknown := getWindowsAccountPrivilegedOrUnknown
// Set test values - inject platform dependencies
getCurrentUser = func() (*user.User, error) {
@@ -53,16 +54,31 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
return euid
}
// Mock privilege detection based on the test user
getIsProcessPrivileged = func() bool {
// Simulate the Windows token elevation check based on the fixture user:
// the built-in Administrator (RID 500) and SYSTEM run elevated.
getProcessElevated = func() bool {
if currentUser == nil {
return false
}
// Check both username and SID for Windows systems
if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) {
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":
return true
}
return isPrivilegedUsername(currentUser.Username)
return false
}
// Return cleanup function
@@ -71,10 +87,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
lookupUser = originalLookupUser
getCurrentOS = originalGetCurrentOS
getEuid = originalGetEuid
getIsProcessPrivileged = isCurrentProcessPrivileged
// Reset caches after test
getProcessElevated = originalGetProcessElevated
getWindowsAccountPrivilegedOrUnknown = originalGetWindowsAccountPrivilegedOrUnknown
}
}
@@ -421,6 +435,9 @@ 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
@@ -432,25 +449,9 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
{"unix_regular_user", "alice", "linux", false},
{"unix_root_capital", "Root", "linux", false}, // Case-sensitive
// Windows tests
// Windows dispatch to the (mocked) account classifier
{"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 {
@@ -459,50 +460,8 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil)
defer cleanup()
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)
result := isPrivilegedOrUnknown(tt.username)
assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform)
})
}
}

View File

@@ -91,7 +91,7 @@ func validateUsernameFormat(username string) error {
func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) {
logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty)
username, _ := s.parseUsername(localUser.Username)
username, _ := 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 := s.parseUsername(localUser.Username)
username, domain := 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 (s *Server) parseUsername(fullUsername string) (username, domain string) {
func parseUsername(fullUsername string) (username, domain string) {
// Handle DOMAIN\username format
if idx := strings.LastIndex(fullUsername, `\`); idx != -1 {
domain = fullUsername[:idx]

View File

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

View File

@@ -1,6 +1,5 @@
[Desktop Entry]
Name=NetBird
Comment=NetBird desktop client
Name=Netbird
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
Icon=netbird
Type=Application

View File

@@ -1,47 +0,0 @@
<?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>

View File

@@ -71,10 +71,12 @@ type BundleOptions = {
hasWindow: boolean;
totalSec: number;
uploadUrl: string;
anonymize: boolean;
anonymizeLevel: AnonymizeLevel;
systemInfo: boolean;
};
export type AnonymizeLevel = "none" | "default" | "strict";
const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => {
try {
// Mirror the CLI's safety margin: window + 30s, server caps at 10m.
@@ -187,7 +189,10 @@ const runBundleFlow = async (
if (opts.uploadUrl) setStage({ kind: "uploading" });
const result = await DebugSvc.Bundle({
anonymize: opts.anonymize,
anonymize: opts.anonymizeLevel !== "none",
// The daemon only knows "default" and "strict"; "none" is expressed
// through the anonymize flag being off.
anonymizeLevel: opts.anonymizeLevel === "strict" ? "strict" : "default",
systemInfo: opts.systemInfo,
uploadUrl: opts.uploadUrl,
logFileCount,
@@ -198,7 +203,7 @@ const runBundleFlow = async (
};
const useDebugBundle = () => {
const [anonymize, setAnonymize] = useState(false);
const [anonymizeLevel, setAnonymizeLevel] = useState<AnonymizeLevel>("none");
const [systemInfo, setSystemInfo] = useState(true);
const [upload, setUpload] = useState(true);
const [trace, setTrace] = useState(true);
@@ -240,7 +245,7 @@ const useDebugBundle = () => {
hasWindow: capture && totalSec > 0,
totalSec,
uploadUrl: upload ? NETBIRD_UPLOAD_URL : "",
anonymize,
anonymizeLevel,
systemInfo,
};
@@ -272,8 +277,8 @@ const useDebugBundle = () => {
};
return {
anonymize,
setAnonymize,
anonymizeLevel,
setAnonymizeLevel,
systemInfo,
setSystemInfo,
upload,

View File

@@ -22,18 +22,12 @@ 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>;
};
@@ -69,12 +63,6 @@ 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;
@@ -85,7 +73,6 @@ 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 });
@@ -107,12 +94,7 @@ const useSettingsState = () => {
username,
});
if (cancelled) 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;
}
if (saveTimer.current) return;
setLoaded({ profileName: activeProfileId, data });
} catch (e) {
if (cancelled || !showError) return;
@@ -159,17 +141,12 @@ const useSettingsState = () => {
async (profileName: string, next: Config, preSharedKey?: string) => {
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
try {
const { declined } = await SettingsSvc.SetConfig({
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
@@ -229,59 +206,6 @@ 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;
@@ -301,27 +225,15 @@ const useSettingsState = () => {
[loaded, save],
);
return {
config: loaded?.data ?? null,
guiVersion,
setField,
saveField,
saveFields,
saveGuardedField,
saveNow,
};
return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } =
useSettingsState();
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
const value = useMemo<SettingsContextValue | null>(
() =>
config
? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow }
: null,
[config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow],
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
[config, guiVersion, setField, saveField, saveFields, saveNow],
);
if (!value) {

View File

@@ -0,0 +1,35 @@
import { useCallback, useEffect, useState } from "react";
import { Preferences } from "@bindings/services";
export const useKeepConnectedOnQuit = () => {
const [keepConnected, setKeepConnected] = useState<boolean | null>(null);
useEffect(() => {
let cancelled = false;
Preferences.Get()
.then((prefs) => {
if (cancelled) return;
setKeepConnected(prefs?.keepConnectedOnQuit ?? false);
})
.catch((err: unknown) => {
if (cancelled) return;
console.warn("[useKeepConnectedOnQuit] load preferences failed", err);
setKeepConnected(false);
});
return () => {
cancelled = true;
};
}, []);
const setKeepConnectedOnQuit = useCallback(async (keep: boolean) => {
setKeepConnected(keep);
try {
await Preferences.SetKeepConnectedOnQuit(keep);
} catch (err: unknown) {
setKeepConnected(!keep);
console.error("[useKeepConnectedOnQuit] SetKeepConnectedOnQuit failed", err);
}
}, []);
return { keepConnected, setKeepConnectedOnQuit };
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Settings as SettingsSvc } from "@bindings/services";
import { type Privilege } from "@bindings/services/models.js";
import { 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

View File

@@ -11,6 +11,7 @@ import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
export function SettingsGeneral() {
const { t } = useTranslation();
@@ -19,6 +20,7 @@ export function SettingsGeneral() {
const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } =
useManagementUrl();
const { mdm, features } = useRestrictions();
const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit();
const inputRef = useRef<HTMLInputElement>(null);
const managementUrlId = useId();
@@ -57,6 +59,15 @@ export function SettingsGeneral() {
helpText={t("settings.general.autostart.help")}
/>
)}
<FancyToggleSwitch
value={keepConnected ?? false}
onChange={(v) => {
void setKeepConnectedOnQuit(v);
}}
loading={keepConnected === null}
label={t("settings.general.keepConnectedOnQuit.label")}
helpText={t("settings.general.keepConnectedOnQuit.help")}
/>
</SectionGroup>
{!mdm.managementURL && !features.disableUpdateSettings && (

View File

@@ -1,4 +1,3 @@
import { type TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
@@ -7,91 +6,51 @@ 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 { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { usePrivilege } from "@/hooks/usePrivilege.ts";
import type { Privilege } from "@bindings/services/models.js";
import { 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, saveGuardedField } = useSettings();
const { config, setField } = 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: 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.
// 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.
//
// 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 = (
field: GuardedField,
guardedDirectionActive: boolean,
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 { apply: plain, disabled: false, hint: undefined };
return { disabled: false, hint: undefined };
}
const guardedDirectionActive = config[field];
const hint = (pending: boolean, command?: string) => (
<GuardedHint
actor={actorLabel(privilege, t)}
const hint = (
<PrivilegeHint
actor={privilege.actor}
command={command(privilege)}
oneWay={guardedDirectionActive}
inverted={inverted}
pending={pending}
command={command}
/>
);
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)),
};
return { disabled: !guardedDirectionActive, hint };
};
const sshServer = guarded("serverSshAllowed", (p) => p.allowSshServer);
const sshRoot = guarded("enableSshRoot", (p) => p.enableSshRoot);
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
const sshRoot = guarded(config.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("disableSshAuth", (p) => p.disableSshAuth, true);
const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true);
const jwtTtlId = useId();
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
@@ -125,7 +84,7 @@ export function SettingsSSH() {
<SectionGroup title={t("settings.ssh.section.server")}>
<FancyToggleSwitch
value={config.serverSshAllowed}
onChange={sshServer.apply}
onChange={(v) => setField("serverSshAllowed", v)}
disabled={sshServer.disabled}
label={t("settings.ssh.server.label")}
helpText={t("settings.ssh.server.help")}
@@ -139,7 +98,7 @@ export function SettingsSSH() {
>
<FancyToggleSwitch
value={config.enableSshRoot}
onChange={sshRoot.apply}
onChange={(v) => setField("enableSshRoot", v)}
disabled={sshRoot.disabled}
label={t("settings.ssh.root.label")}
helpText={t("settings.ssh.root.help")}
@@ -171,7 +130,7 @@ export function SettingsSSH() {
>
<FancyToggleSwitch
value={!config.disableSshAuth}
onChange={(v) => sshAuth.apply(!v)}
onChange={(v) => setField("disableSshAuth", !v)}
disabled={sshAuth.disabled}
label={t("settings.ssh.jwt.label")}
helpText={t("settings.ssh.jwt.help")}
@@ -204,81 +163,41 @@ export function SettingsSSH() {
);
}
// 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({
// 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({
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"
}
>
{children}
<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>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useId, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next";
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
@@ -8,13 +8,22 @@ import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import HelpText from "@/components/typography/HelpText.tsx";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { SquareIcon } from "@/components/SquareIcon";
import { Tooltip } from "@/components/Tooltip";
import { cn } from "@/lib/cn";
import { formatRemaining } from "@/lib/formatters";
import type { DebugStage } from "@/contexts/DebugBundleContext";
import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext";
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
@@ -24,8 +33,8 @@ export function SettingsTroubleshooting() {
const { t } = useTranslation();
const durationId = useId();
const {
anonymize,
setAnonymize,
anonymizeLevel,
setAnonymizeLevel,
systemInfo,
setSystemInfo,
upload,
@@ -55,12 +64,71 @@ export function SettingsTroubleshooting() {
return (
<SectionGroup title={t("settings.troubleshooting.section.title")}>
<FancyToggleSwitch
value={anonymize}
onChange={setAnonymize}
label={t("settings.troubleshooting.anonymize.label")}
helpText={t("settings.troubleshooting.anonymize.help")}
/>
<div className={"flex items-center justify-between gap-6"}>
<div className={"max-w-md flex-1"}>
<Label as={"div"}>
<span className={"inline-flex items-center gap-1.5"}>
{t("settings.troubleshooting.anonymize.label")}
<Tooltip
content={
<div className={"max-w-xs whitespace-normal leading-relaxed"}>
{t("settings.troubleshooting.anonymize.info")}
</div>
}
>
<Info
size={14}
aria-label={t("settings.troubleshooting.anonymize.label")}
className={"shrink-0 cursor-default text-nb-gray-400"}
/>
</Tooltip>
</span>
</Label>
<HelpText margin={false}>
{t("settings.troubleshooting.anonymize.help")}
</HelpText>
</div>
<div className={"shrink-0"}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
aria-label={t("settings.troubleshooting.anonymize.label")}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center justify-between gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
)}
>
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
<ChevronDown
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[160px]"}>
<DropdownMenuRadioGroup
value={anonymizeLevel}
onValueChange={(v) => setAnonymizeLevel(v as AnonymizeLevel)}
>
<DropdownMenuRadioItem value={"none"}>
{t("settings.troubleshooting.anonymize.none")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={"default"}>
{t("settings.troubleshooting.anonymize.default")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={"strict"}>
{t("settings.troubleshooting.anonymize.strict")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<FancyToggleSwitch
value={systemInfo}
onChange={setSystemInfo}

View File

@@ -2,9 +2,24 @@
A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating).
**Drive an agent with:** *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* — or *"…and review the existing German translation."*
**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.
> 💡 **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.
> 💡 **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.
---
@@ -30,25 +45,6 @@ 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.
@@ -58,7 +54,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 description flags |
| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the context 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.
@@ -78,13 +74,15 @@ 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 bundles:** 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 translation:** 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
@@ -98,7 +96,7 @@ Two checks before you commit a term:
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 bundle reading like one product rather than a word-for-word port:
A few habits that keep a translation 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.
@@ -107,27 +105,26 @@ A few habits that keep a bundle reading like one product rather than a word-for-
---
## Procedure
## Reviewing a language
**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`).
**On Crowdin:** proofread in the editor — context, glossary highlights, and QA flags sit inline next to each string.
**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.
**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.
---
## 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 bundle for your language)
- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing translation for your language)
- [ ] Buttons & tray short · locale punctuation and capitalization applied
- [ ] New language added to `_index.json`
- [ ] Crowdin QA flags resolved (variables, glossary terms, punctuation)
- [ ] **Tested in the running app**
---
## Test it in the app
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.
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.
How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step.

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Alle sichtbaren Ressourcen umschalten"
},
"settings.nav.label": {
"message": "Einstellungsbereiche"
},
"profile.switch.title": {
"message": "Zu Profil \"{name}\" wechseln?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Debug-Paket fehlgeschlagen"
},
"settings.nav.label": {
"message": "Einstellungsbereiche"
},
"settings.tabs.general": {
"message": "Allgemein"
},
@@ -551,6 +551,14 @@
"settings.general.autostart.errorTitle": {
"message": "Ändern des Autostarts fehlgeschlagen"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Nach dem Beenden verbunden bleiben",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Anzeigesprache"
},
@@ -1330,29 +1338,5 @@
},
"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…"
}
}

View File

@@ -735,6 +735,14 @@
"message": "Autostart Change Failed",
"description": "Error-dialog title when changing the autostart setting fails."
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Stay Connected After Quitting",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "The connection stays up in the background after you close NetBird. It only stops when you disconnect it yourself.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Display Language",
"description": "Label for the display-language picker."
@@ -1005,11 +1013,27 @@
},
"settings.troubleshooting.anonymize.label": {
"message": "Anonymize Sensitive Information",
"description": "Toggle label: anonymize sensitive information in the bundle."
"description": "Label for the anonymization level dropdown (None, Default, Strict)."
},
"settings.troubleshooting.anonymize.help": {
"message": "Hides public IP addresses and non-NetBird domains from logs.",
"description": "Helper text for anonymizing logs (hides public IPs and non-NetBird domains)."
"message": "Hides IP addresses, domains, and other sensitive values.",
"description": "Helper text under the anonymization dropdown. The level details live in the info tooltip."
},
"settings.troubleshooting.anonymize.info": {
"message": "Default keeps internal IPv4 addresses and peer names readable for support. Strict additionally anonymizes private (RFC 1918), CGNAT, and link-local IP addresses, peer names, and WireGuard public keys. Recurring values map to the same placeholder, so peers stay distinguishable. Use Strict when sharing the bundle outside your organization.",
"description": "Info tooltip explaining the anonymization levels. 'RFC 1918', 'CGNAT', 'link-local', and 'WireGuard' are technical terms — keep them."
},
"settings.troubleshooting.anonymize.none": {
"message": "None",
"description": "Dropdown option: no anonymization."
},
"settings.troubleshooting.anonymize.default": {
"message": "Default",
"description": "Dropdown option: default anonymization level."
},
"settings.troubleshooting.anonymize.strict": {
"message": "Strict",
"description": "Dropdown option: strict anonymization level."
},
"settings.troubleshooting.systemInfo.label": {
"message": "Include System Information",
@@ -1775,36 +1799,16 @@
"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": "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."
"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."
},
"settings.ssh.privilege.oneWayInverted": {
"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."
"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."
}
}

View File

@@ -401,6 +401,9 @@
"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}»?"
},
@@ -494,9 +497,6 @@
"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,6 +551,14 @@
"settings.general.autostart.errorTitle": {
"message": "Error al cambiar el inicio automático"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Permanecer conectado al salir",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Idioma de la interfaz"
},
@@ -1330,29 +1338,5 @@
},
"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…"
}
}

View File

@@ -401,6 +401,9 @@
"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} » ?"
},
@@ -494,9 +497,6 @@
"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,6 +551,14 @@
"settings.general.autostart.errorTitle": {
"message": "Échec de la modification du démarrage automatique"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Rester connecté après la fermeture",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Langue daffichage"
},
@@ -1330,29 +1338,5 @@
},
"error.unknown": {
"message": "Lopération a échoué."
},
"error.elevation_unavailable": {
"message": "NetBird na pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :"
},
"error.elevation_failed": {
"message": "La modification na 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 lactiver, mais le désactiver de nouveau nécessite {actor}."
},
"settings.ssh.privilege.authorizePending": {
"message": "En attente de lautorisation…"
}
}

View File

@@ -401,6 +401,9 @@
"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?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Hibakeresési csomag sikertelen"
},
"settings.nav.label": {
"message": "Beállítások szakaszai"
},
"settings.tabs.general": {
"message": "Általános"
},
@@ -551,6 +551,14 @@
"settings.general.autostart.errorTitle": {
"message": "Az automatikus indítás módosítása sikertelen"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Kapcsolat megtartása kilépéskor",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Megjelenítési nyelv"
},
@@ -1330,29 +1338,5 @@
},
"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…"
}
}

View File

@@ -401,6 +401,9 @@
"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}»?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Pacchetto di debug non riuscito"
},
"settings.nav.label": {
"message": "Sezioni delle impostazioni"
},
"settings.tabs.general": {
"message": "Generale"
},
@@ -551,6 +551,14 @@
"settings.general.autostart.errorTitle": {
"message": "Modifica avvio automatico non riuscita"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Resta connesso dopo la chiusura",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Lingua dell'interfaccia"
},
@@ -1330,29 +1338,5 @@
},
"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…"
}
}

View File

@@ -551,6 +551,14 @@
"settings.general.autostart.errorTitle": {
"message": "自動起動の変更に失敗しました"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "終了後も接続を維持",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "表示言語"
},
@@ -1304,9 +1312,6 @@
"daemon.outdated.description": {
"message": "このアプリを使用するには NetBird サービスを更新してください。"
},
"daemon.outdated.download": {
"message": "最新版をダウンロード"
},
"error.jwt_clock_skew": {
"message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。"
},
@@ -1330,29 +1335,5 @@
},
"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": "承認を待っています…"
}
}

View File

@@ -401,6 +401,9 @@
"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}\"?"
},
@@ -494,9 +497,6 @@
"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,6 +551,14 @@
"settings.general.autostart.errorTitle": {
"message": "Falha ao alterar o início automático"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Permanecer conectado ao sair",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Idioma de exibição"
},
@@ -1330,29 +1338,5 @@
},
"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…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "Переключить все видимые ресурсы"
},
"settings.nav.label": {
"message": "Разделы настроек"
},
"profile.switch.title": {
"message": "Переключиться на профиль «{name}»?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "Не удалось создать отладочный пакет"
},
"settings.nav.label": {
"message": "Разделы настроек"
},
"settings.tabs.general": {
"message": "Общие"
},
@@ -551,6 +551,14 @@
"settings.general.autostart.errorTitle": {
"message": "Не удалось изменить автозапуск"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "Оставаться подключённым после выхода",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "Язык интерфейса"
},
@@ -1330,29 +1338,5 @@
},
"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": "Ожидание авторизации…"
}
}

View File

@@ -401,6 +401,9 @@
"networks.bulk.label": {
"message": "切换所有可见资源"
},
"settings.nav.label": {
"message": "设置部分"
},
"profile.switch.title": {
"message": "切换到配置文件“{name}”?"
},
@@ -494,9 +497,6 @@
"settings.error.debugBundleTitle": {
"message": "创建调试包失败"
},
"settings.nav.label": {
"message": "设置部分"
},
"settings.tabs.general": {
"message": "常规"
},
@@ -551,6 +551,14 @@
"settings.general.autostart.errorTitle": {
"message": "更改自启动设置失败"
},
"settings.general.keepConnectedOnQuit.label": {
"message": "退出后保持连接",
"description": "Toggle label: keep the VPN connection up after quitting the UI."
},
"settings.general.keepConnectedOnQuit.help": {
"message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。",
"description": "Helper text for the stay-connected-after-quitting toggle."
},
"settings.general.language.label": {
"message": "显示语言"
},
@@ -1330,29 +1338,5 @@
},
"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": "正在等待授权…"
}
}

View File

@@ -8,7 +8,6 @@ import (
"flag"
"io/fs"
"log"
"os"
"runtime"
"strings"
@@ -80,14 +79,6 @@ 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)
@@ -189,6 +180,7 @@ func main() {
WindowManager: windowManager,
Session: authSession,
Localizer: localizer,
Preferences: prefStore,
})
listenForShowSignal(context.Background(), tray)

View File

@@ -58,6 +58,10 @@ type UIPreferences struct {
// decision has run for this OS user. It only ever transitions to true
// and is never reset, so the default-on flow runs at most once, ever.
AutostartInitialized bool `json:"autostartInitialized"`
// KeepConnectedOnQuit leaves the daemon connected when the GUI quits.
// Its false zero value preserves the historical disconnect-on-quit
// behaviour for preference files written before the field existed.
KeepConnectedOnQuit bool `json:"keepConnectedOnQuit"`
}
// LanguageValidator rejects SetLanguage inputs with no shipped bundle.
@@ -183,6 +187,26 @@ func (s *Store) SetAutostartInitialized(done bool) error {
return nil
}
// SetKeepConnectedOnQuit persists the disconnect-on-quit opt-out. No-op if unchanged.
func (s *Store) SetKeepConnectedOnQuit(keep bool) error {
s.mu.Lock()
if s.current.KeepConnectedOnQuit == keep {
s.mu.Unlock()
return nil
}
next := s.current
next.KeepConnectedOnQuit = keep
if err := s.persistLocked(next); err != nil {
s.mu.Unlock()
return fmt.Errorf("persist preferences: %w", err)
}
s.current = next
s.mu.Unlock()
s.broadcast(next)
return nil
}
// SetLanguage validates, persists, and broadcasts. No-op if unchanged.
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
if lang == "" {

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