Compare commits

...

38 Commits

Author SHA1 Message Date
Brad Ison
6c9267c7d3 [management] State the sqlite precision caveat without the review history
A comment explaining why the truncation guard sits on the type recorded how
the gap came to be noticed rather than the constraint itself, which AGENTS.md
rules out. The technical point is the one worth keeping: sqlite preserves
nanoseconds, so a sqlite-only suite cannot observe the truncation.

The neighbouring back-compatibility comment gets the same treatment — "clients
written before this existed" leans on the reader knowing what "this" was, and
naming conditional requests outright says it plainly.
2026-08-11 12:33:23 +02:00
Brad Ison
29eff3b207 [management] Derive the settings ETag at a precision the store preserves
The validator a bootstrap returns was permanently unusable on PostgreSQL. POST
derives it from the in-memory row, whose CreatedAt carries nanoseconds, while
every later comparison derives it from a row read back out of the store — and
PostgreSQL truncates timestamps to microseconds, MySQL DATETIME to whole
seconds without an fsp. The two never agreed again, so the documented
"conditional PUT without an intervening GET" answered 412 forever.

Hashing CreatedAt at whole-second precision fixes it at the root: seconds is
the floor every supported engine round-trips, so the validator no longer
depends on which store is behind it. The cost is that a delete and
re-bootstrap inside the same second, onto the same endpoint with the same
toggles, derives the same validator — which needs a self-addressed endpoint
reclaimed within one second, since a labeled bootstrap draws a fresh label.

The whole suite ran green against this bug, because every test uses the sqlite
test store and sqlite preserves nanoseconds. The regression guard is therefore
on the type rather than through a store: it asserts the validator is unchanged
by truncation at each engine's precision, so it holds without running the
suite against all three.

Also from review: the concurrency test recorded the winning writer's value in a
slice left zero for the loser, and zero is a retention the API documents as
"keep indefinitely" — so the assertion could be satisfied without matching the
writer that won. It now uses a sentinel and asserts equality. The stale-delete
e2e assertion accepted any error, which a server error or a state-guard refusal
would have satisfied; it now requires a precondition failure. And the 412
message both conditional writes return is a single constant, since on delete
the message is the only thing separating staleness from the state guards.
2026-08-11 12:24:53 +02:00
Brad Ison
35137326f7 [management] Expose the ETag response header to browser clients
ETag is not a CORS-safelisted response header, so JavaScript cannot read it
unless the server names it in Access-Control-Expose-Headers. Without that the
API hands a browser client a validator it has no way to see, and conditional
requests are available to the CLI, the REST client and Terraform but silently
not to the dashboard — the one client where a lost update is a person's work
disappearing rather than a plan reverting.

The library offers no way to extend cors.AllowAll(), so the policy is spelled
out verbatim with ExposedHeaders added. That is a wider blast radius than the
one field deserves, which is why the tests assert the rest of the policy too:
any origin, no credentials, the full method set. If-Match needs no grant of
its own, since AllowedHeaders is already "*" — the preflight test guards
against a later narrowing that would leave writes readable but not
conditional.

Collapsing this back to cors.AllowAll() is the obvious tidy-up and would
silently undo it, which is what the tests are there to catch.
2026-08-11 11:51:00 +02:00
Brad Ison
5085a2f96b [management] Surface the settings ETag through the REST client
The Terraform provider consumes this client, and it is the client that has the
read-modify-write problem conditional requests solve, so the server-side work
is inert until the validator reaches it.

The new methods are additive and the existing four delegate to them unchanged.
A breaking signature change would buy a tidier surface at the cost of every
current caller, with no deprecation window, and the plain methods remain the
right default for callers that do not care.

CreateSettingsWithETag exists because the bootstrap emits a validator too, and
discarding it would force a client to read again before its first conditional
write — the round trip the header is there to avoid.

IsPreconditionFailed joins IsNotFound because a refused precondition is
otherwise an opaque APIError. Telling "someone else changed this, read again
and retry" apart from a genuine failure is the decision a conditional client
has to make, and it should not have to compare status codes by hand.

Validators arrive unquoted and go back out quoted, so no caller handles the
wire syntax. The e2e coverage drives all of it through the typed client
against a real server, playing out the case that motivates the feature: a
client plans an update, an operator turns PII redaction on in between, and the
client's write is refused instead of silently turning it back off.
2026-08-11 11:44:15 +02:00
Brad Ison
30c2010c09 [management] Document the Agent Network settings ETag in the API spec
Declares what the handlers already do: the settings reads and writes return an
ETag, and PUT and DELETE honour an If-Match against it. The 412 that PUT can
now return is documented alongside, and DELETE's existing 412 grows a note
that a precondition failure shares the status with its two state guards and is
told apart by the message rather than the code.

Response headers generate nothing under a models-only codegen config, so those
entries are for human and third-party consumers. The If-Match parameters do
generate — hence the two Params structs in types.gen.go, which nothing calls
yet but which the REST client can take up.
2026-08-11 11:36:18 +02:00
Brad Ison
9b06290240 [management] Honour If-Match on Agent Network settings writes
With the validator derived and the helper in place, wire both together: reads
and writes hand back an ETag, and PUT and DELETE honour an If-Match against it.
A client that plans against one read and writes the whole object back now gets
refused instead of silently reverting whatever changed in between.

The comparison happens inside the write's own transaction, under the row lock
the fetch already takes, rather than in the handler. Comparing before the
transaction merely narrows the race: two writers read the same row, both find
their precondition satisfied, and both then write. That is the lost update
this is meant to prevent, and the concurrency test added here fails against
that shape while passing against this one.

It also runs ahead of each operation's other guards. A caller stale enough to
be holding an old validator is stale in its identity echo too, so answering
"you are working from an old read" is more useful than "the endpoint is
immutable"; on delete it matters more still, because the provider guard raises
the same 412 and would send a stale client hunting for providers it may not
know exist.

If-Match stays optional. The dashboard is human-driven, where last-write-wins
is acceptable and a surprise 412 is worse than a benign overwrite; Terraform
is machine-driven and can opt in. Making it mandatory would cost a dashboard
change and should use 428 when that day comes.

GET emits a validator for the pre-bootstrap defaults as well: they are a
representation like any other, and validating them means an If-Match taken
before bootstrap cannot match the row that appeared since.
2026-08-11 11:28:17 +02:00
Brad Ison
829156f53d [management] Add ETag and If-Match helpers for HTTP handlers
The read-modify-write pattern conditional requests guard is not specific to
one resource — every Terraform-managed resource has the same shape, so the
second consumer of this is a matter of when, not if. Deciding the entity-tag
quoting, the "*" form, the list form and the weak-validator rule once here
means an adopting resource inherits all of them instead of re-deriving each.
Only the derivation stays on the type, since only the type knows which of its
fields the representation covers.

IfMatch returns a nil-safe precondition rather than a slice of tags. If-Match
is defined in terms of the strong comparison function, so a weak validator can
never satisfy it — and a header carrying nothing but weak validators then
parses to an empty list, which the obvious call-site check reads as "no
precondition given" and lets the write through unguarded. That is precisely
the failure this is meant to prevent, and it is reachable by a proxy that
weakens an ETag in transit. An absent header is unconditional; a header that
was sent but carries nothing usable refuses.

Weak validators are dropped rather than unwrapped, for the same reason:
unwrapping one into a strong tag would quietly grant a match that the client's
own header said it could not have.

No handler uses this yet.
2026-08-11 11:16:27 +02:00
Brad Ison
4f6caa1110 [management] Derive an ETag for Agent Network settings
A client that reads the Agent Network settings, computes a change and writes
the whole object back will silently revert anything that changed in between —
Terraform's read-modify-write is exactly this shape. The consequence worth
guarding is RedactPii: an operator enabling PII redaction in the dashboard can
have it turned back off by an apply that was planned before their change, with
no error and no drift warning on that run.

Conditional requests need a validator. Derive one instead of storing it: a
timestamp cannot separate two writes within a clock tick, and a revision column
costs a migration plus the bump-discipline that goes with it. A hash of the
representation needs neither and is correct by construction.

The hash covers an explicit field tuple rather than the marshalled API types,
whose field ordering is not a contract. AccountID stays out — it identifies the
resource, not the representation — and so does UpdatedAt, so that a write
changing nothing observable cannot invalidate a precondition someone else is
holding. The identity fields and CreatedAt are in: a validator over only the
mutable toggles would survive a delete and fresh bootstrap onto the same toggle
values, and an If-Match held across that gap would then authorize a write
against what is really a different resource.

This only derives the validator. Nothing emits or honours it yet.
2026-08-11 11:12:06 +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
Maycon Santos
6526fc2bec [management] Prevent deleting groups referenced by reverse proxy services (#7062)
## Describe your changes

A group could be deleted while a reverse proxy service still referenced
it, silently breaking the service's access control: private services
list groups in `access_groups` as the peer allowlist, and SSO bearer
auth distributes tokens to `distribution_groups`.

Group deletion now runs through the same linkage validation as routes,
policies, and agent network policies: deleting a group that backs a
private service allowlist or an enabled bearer-auth distribution list
fails with a `GroupLinkError` naming the service domain. Disabled bearer
configs and stale `access_groups` on non-private services are inert and
do not block deletion.

Tests cover both linked cases in single and bulk deletion, and pin the
non-blocking cases. The test account seeds decoy services ahead of the
linked ones so the check is proven to scan the full service list.
2026-08-05 03:24:20 +09:00
Misha Bragin
2afa69b622 [management] prevent dangling group refs in agent-network ACLs. (#7060)
Block deleting a group referenced as a source group by an agent network
   policy, and drop unresolvable groups from synthesised private-service
ACLs. A deleted group survived in agent_network_policies.source_groups
   and was carried into the injected in-memory policy, where network-map
   assembly resolved it to a nil group and panicked on every proxy peer
   sync.
2026-08-04 18:04:22 +02:00
Zoltan Papp
2a61eac047 [client] Fix Linux tray right-click opening the main window (#7039)
## Describe your changes

On SNI hosts that report icon clicks via Activate (KDE Plasma, Waybar),
Wails fired the left-click handler on every dbusmenu 'opened' event, so
a right click opened the tray menu and immediately raised the main
window, which stole focus and closed the menu.

	Host                           Left click              Right click
KDE Plasma, Waybar main window (Activate) menu (host-rendered)
	GNOME Shell + AppIndicator     menu only               menu only
	Minimal WMs via XEmbed host    main window (Activate)  XEmbed GTK popup

Point the wails/v3 replace at the netbirdio fork (v3.0.0-beta.3 plus the
fix): once the host has sent Activate/SecondaryActivate, a menu open no
longer fires the click handler, while AppIndicator-only hosts that
signal clicks solely via 'opened' keep the old behavior.


## 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 underlying application components to support compatibility and
ongoing maintenance.
* Clarified Linux tray interaction documentation, including
platform-specific left- and right-click behavior and menu activation.
* No user-facing features, workflow changes, or visual updates are
included.
* Existing Linux tray behavior remains unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 17:36:04 +02:00
Zoltan Papp
f2d13b884a [client] Fix session expired relogin (#7055)
## Describe your changes

After the SSO session expires, the daemon tears the engine down
permanently
(management returns `PermissionDenied` → `runCancel()` → the retry loop
exits
for good). The "Session expired" dialog's Login button still drove the
extend-session flow, which requires a live engine: the user completed
the full
browser SSO + 2FA round trip only to get
`Failed to extend the session — engine is not initialised`, with no way
out
other than quitting and relaunching the client.

Reproduce:
1. Log in on a desktop client with session expiration enabled (e.g. 16h
TTL).
2. Let the session expire (e.g. leave the machine asleep overnight).
3. Wake it, click **Login** on the "Session expired" dialog, complete
SSO + 2FA.
4. The error dialog appears and every retry fails the same way.

Changes:
- The expired branch of the session-expiration dialog now emits
`trigger-login`, driving the full `Login → SSO → Up` sequence that
rebuilds
the client, instead of the extend flow (an expired session can no longer
be
  extended).
- `RequestExtendAuthSession` fails fast when the engine is already gone,
so the
  browser/2FA round trip is not wasted on a doomed extend.
- The expired tray row navigated the main window to `/#/login`, a route
that
does not exist and fell through to the main page without starting a
login;
  it now emits `trigger-login` as well.


## 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 session extension handling when the client engine is
unavailable by prompting users to log in again.
- Updated expired-session behavior to trigger the standard login flow,
providing a more consistent sign-in experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 16:02:31 +02:00
Zoltan Papp
564595d283 [client, android] Fix profile account path test on Windows (#7057)
## Describe your changes

The expected paths were hardcoded with Unix separators while
profileAccountPathFor builds the result with filepath.Join, so the
comparison failed on Windows. Derive the expectations with
filepath.FromSlash to keep the test platform-independent.


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

* **Tests**
* Updated profile account path test expectations to use
platform-appropriate path separators, improving test reliability across
operating systems.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 16:02:09 +02:00
Zoltan Papp
78c1c2fc32 [client] Probe the daemon login with IsLoginRequired (#7052)
## Describe your changes

Probe the daemon login with IsLoginRequired 

The Login probe attemptLogin(ctx, "", "") on an unregistered peer ends
in registerPeer with no setup key and no JWT, which fails locally with
InvalidArgument before reaching Management. Since #6983 classified that
as StatusLoginFailed and returned early, every setup-key enrolment and
every expired-session SSO re-login aborted before using its credentials,
breaking all netbird-cloud e2e runs from commit e90be36cd.

IsLoginRequired asks the question the probe actually means - is the
peer's key alone still accepted - and reports Management's refusal as a
decision (needsLogin) instead of an error, the same pattern
foregroundLogin, Android and iOS already use.

## 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 login handling when Management connectivity checks fail.
* Prevented unnecessary SSO prompts for already-authenticated sessions.
* Preserved setup-key login behavior while ensuring authentication
attempts proceed correctly.
* Login failures now return a clear failure status when authentication
state cannot be verified.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 14:27:47 +02:00
144 changed files with 18774 additions and 2270 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

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

142
.goreleaser_ui_gtk3.yaml Normal file
View File

@@ -0,0 +1,142 @@
version: 2
env:
- SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
project_name: netbird-ui
before:
hooks:
# Bindings are gitignored; regenerate before the frontend build so
# the @wailsio/runtime Vite plugin can resolve them (vite refuses to
# build without them).
# -f '-tags gtk3': the generator type-checks client/ui, whose cgo imports
# would otherwise resolve gtk4/webkitgtk-6.0 pkg-config entries that do
# not exist on ubuntu-22.04.
- sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts -f "-tags gtk3"'
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
builds:
# Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0
# (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). The gtk3 tag flips the
# Wails Linux backend to the GTK3 stack and swaps our GTK4-only XEmbed
# tray host for the pure-Go stub (client/ui/xembed_host_gtk3_linux.go).
# Must be built on the oldest supported glibc (ubuntu-22.04 runner).
- id: netbird-ui-gtk3
dir: client/ui
binary: netbird-ui
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- production
- gtk3
archives:
- id: linux-gtk3-arch
name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui-gtk3
nfpms:
# Same package_name as the GTK4 packages -- the two are mutually-exclusive
# alternatives served from separate repo paths (see uploads below); a given
# distro points at exactly one of them. The file names must still differ:
# the Debian pool is shared storage keyed by file name, so a default-named
# gtk3 .deb would overwrite the stable one.
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_deb_gtk3
package_name: netbird-ui
file_name_template: "{{ .PackageName }}-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui-gtk3
formats:
- deb
scripts:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird (>= 0.75.0)
- libgtk-3-0
- libwebkit2gtk-4.1-0
- xdg-utils
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
homepage: https://netbird.io/
license: BSD-3-Clause
vendor: NetBird
id: netbird_ui_rpm_gtk3
package_name: netbird-ui
file_name_template: "{{ .PackageName }}-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
builds:
- netbird-ui-gtk3
formats:
- rpm
scripts:
postinstall: "release_files/ui-post-install.sh"
contents:
- src: client/ui/build/linux/netbird.desktop
dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird >= 0.75.0
- (gtk3 or libgtk-3-0)
- (webkit2gtk4.1 or libwebkit2gtk-4_1-0)
- xdg-utils
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
# The GTK4 UI job shares project_name, so the default checksum file name would
# collide with it on the shared GitHub release.
checksum:
name_template: "{{ .ProjectName }}_gtk3_checksums.txt"
changelog:
disable: true
uploads:
# The gtk3 packages reuse the netbird-ui package name, so they live in
# dedicated repo paths (deb distribution `gtk3`, yum path `yum-gtk3`) that
# legacy distros point their repo config at.
#
# GoReleaser derives the credential env var from the upload name, so these
# would look for UPLOAD_DEBIAN-GTK3_SECRET / UPLOAD_YUM-GTK3_SECRET. The
# release workflow only exports UPLOAD_DEBIAN_SECRET / UPLOAD_YUM_SECRET, and
# a missing secret is a silent skip rather than a failure -- the packages
# reached the GitHub release but never the package repositories. Point
# `password` at the exported vars so both uploads authenticate.
- name: debian-gtk3
skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_deb_gtk3
mode: archive
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
username: dev@wiretrustee.com
password: "{{ .Env.UPLOAD_DEBIAN_SECRET }}"
method: PUT
- name: yum-gtk3
skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_rpm_gtk3
mode: archive
target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
username: dev@wiretrustee.com
password: "{{ .Env.UPLOAD_YUM_SECRET }}"
method: PUT

317
AGENTS.md
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

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

@@ -16,17 +16,17 @@ func TestProfileAccountPathFor(t *testing.T) {
{
name: "default profile",
configPath: "/data/data/io.netbird.client/files/netbird.cfg",
want: "/data/data/io.netbird.client/files/netbird.account.json",
want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"),
},
{
name: "id profile",
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
want: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json",
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
},
{
name: "legacy name-keyed profile is handled the same way",
configPath: "/data/data/io.netbird.client/files/profiles/work.json",
want: "/data/data/io.netbird.client/files/profiles/work.account.json",
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"),
},
{
name: "empty path is rejected",

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

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

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

View File

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

View File

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

View File

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

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

@@ -8,8 +8,6 @@ import (
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
@@ -27,9 +25,9 @@ func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T
unreachable := errors.New("create connection: dial context: context deadline exceeded")
attempts := 0
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
s.isLoginRequiredFn = func(context.Context) (bool, error) {
attempts++
return internal.StatusLoginFailed, unreachable
return false, unreachable
}
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
@@ -55,15 +53,12 @@ func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
s.rootCtx = internal.CtxInitState(context.Background())
breakProfilePrivateKey(t, cfgPath)
refused := gstatus.Error(codes.PermissionDenied, "peer is not registered")
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
return internal.StatusNeedsLogin, refused
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return true, nil
}
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
require.NotErrorIs(t, err, refused,
"the refusal was handed back to the caller instead of starting the SSO flow")
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, stateErr)
@@ -71,6 +66,32 @@ func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
"the SSO flow setup was never reached with the broken key")
}
func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return true, nil
}
var keysTried []string
s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) {
keysTried = append(keysTried, setupKey)
return "", nil
}
setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F"
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey})
require.NoError(t, err, "the probe's outcome leaked out as the login result")
require.NotNil(t, resp)
require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt")
require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow")
status, err := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, err)
require.Equal(t, internal.StatusIdle, status)
}
// breakProfilePrivateKey replaces the profile's private key with an unparseable
// one, which makes any attempt to build a Management client fail on the spot.
func breakProfilePrivateKey(t *testing.T, cfgPath string) {

View File

@@ -140,6 +140,8 @@ type Server struct {
// it to drive the login outcomes that need a server on the other end;
// production leaves it nil, and every login goes through loginAttempt.
loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error)
isLoginRequiredFn func(ctx context.Context) (bool, error)
}
type oauthAuthFlow struct {
@@ -384,6 +386,21 @@ func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (i
return s.loginAttempt(ctx, setupKey, jwtToken)
}
func (s *Server) isLoginRequired(ctx context.Context) (bool, error) {
if s.isLoginRequiredFn != nil {
return s.isLoginRequiredFn(ctx)
}
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
if err != nil {
log.Errorf("failed to create auth client: %v", err)
return false, err
}
defer authClient.Close()
return authClient.IsLoginRequired(ctx)
}
// loginAttempt attempts to login using the provided information. It returns
// StatusNeedsLogin when Management refused the peer's credentials and
// StatusLoginFailed for every other failure, so callers can tell an
@@ -640,22 +657,22 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
s.config = config
s.mutex.Unlock()
loginStatus, err := s.attemptLogin(ctx, "", "")
if err == nil {
state.Set(internal.StatusIdle)
return &proto.LoginResponse{}, nil
}
// Only an authentication refusal means the peer has to (re-)authenticate.
// Any other failure leaves the login undecided: Management unreachable, a
// A probe that errors leaves the login undecided: Management unreachable, a
// restart mid-request, an internal error. Those are returned for the caller
// to retry, because turning them into an SSO prompt asks the user to solve
// something that is not theirs to solve, and a browser login cannot succeed
// while Management is unreachable anyway.
if loginStatus != internal.StatusNeedsLogin {
state.Set(loginStatus)
// while Management is unreachable anyway. Only Management refusing the
// peer's key is a decision, and IsLoginRequired reports that as
// needsLogin=true rather than an error.
needsLogin, err := s.isLoginRequired(ctx)
if err != nil {
state.Set(internal.StatusLoginFailed)
return nil, err
}
if !needsLogin {
state.Set(internal.StatusIdle)
return &proto.LoginResponse{}, nil
}
if msg.SetupKey == "" {
hint := ""
@@ -1798,6 +1815,9 @@ func (s *Server) RequestExtendAuthSession(
if connectClient == nil {
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
}
if connectClient.Engine() == nil {
return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect")
}
hint := ""
if msg.Hint != nil {

View File

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

View File

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

View File

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

@@ -11,7 +11,7 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { EVENT_BROWSER_LOGIN_CANCEL } from "@/lib/connection";
import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
import { formatRemaining } from "@/lib/formatters";
@@ -131,6 +131,21 @@ export default function SessionExpirationDialog() {
}
}, [busy, t]);
const authenticate = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
await Events.Emit(EVENT_TRIGGER_LOGIN);
await WindowManager.CloseSessionExpiration();
} catch (e) {
setBusy(false);
await errorDialog({
Title: t("connect.error.loginTitle"),
Message: formatErrorMessage(e),
});
}
}, [busy, t]);
const logout = useCallback(async () => {
if (busy) return;
setBusy(true);
@@ -185,7 +200,7 @@ export default function SessionExpirationDialog() {
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={stay}
onClick={expired ? authenticate : stay}
disabled={busy}
>
{expired ? t("sessionExpiration.authenticate") : t("sessionExpiration.stay")}

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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": "表示言語"
},

View File

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

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

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": "显示语言"
},

View File

@@ -180,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 == "" {

View File

@@ -238,6 +238,42 @@ func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) {
assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk")
}
func TestStore_SetKeepConnectedOnQuitPersistsAcrossReload(t *testing.T) {
withTempConfigDir(t)
emitter := &recordingEmitter{}
s, err := NewStore(nil, emitter)
require.NoError(t, err)
assert.False(t, s.Get().KeepConnectedOnQuit, "quitting must disconnect by default")
require.NoError(t, s.SetKeepConnectedOnQuit(true))
assert.True(t, s.Get().KeepConnectedOnQuit, "Get should reflect the persisted opt-out")
require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first write should broadcast")
require.NoError(t, s.SetKeepConnectedOnQuit(true))
assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent write should not broadcast again")
reloaded, err := NewStore(nil, nil)
require.NoError(t, err)
assert.True(t, reloaded.Get().KeepConnectedOnQuit, "opt-out must survive a reload from disk")
}
func TestStore_KeepConnectedOnQuitDefaultsFalseForPreExistingFile(t *testing.T) {
withTempConfigDir(t)
// A preferences file written before the field existed must keep the
// historical disconnect-on-quit behaviour rather than silently opting out.
path, err := preferencesPath()
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(`{"language":"en","viewMode":"default"}`), 0o600))
s, err := NewStore(nil, nil)
require.NoError(t, err)
assert.False(t, s.Get().KeepConnectedOnQuit, "a file predating the field must not opt out of disconnect-on-quit")
assert.True(t, s.ExistedAtLoad(), "the pre-existing file must be seen on disk")
}
func TestStore_ExistedAtLoad(t *testing.T) {
withTempConfigDir(t)

View File

@@ -49,5 +49,11 @@ func getCursorPosition(app *application.App) (application.Point, bool) {
if app == nil || app.Screen == nil {
return p, true
}
// The wails GTK3 backend caches screens from the active window; a tray app
// has none at startup, so the cache is empty and PhysicalToDipPoint would
// dereference a nil nearest screen. Raw pixels are correct there anyway.
if app.Screen.ScreenNearestPhysicalPoint(p) == nil {
return p, true
}
return app.Screen.PhysicalToDipPoint(p), true
}

View File

@@ -15,10 +15,13 @@ import (
)
type DebugBundleParams struct {
Anonymize bool `json:"anonymize"`
SystemInfo bool `json:"systemInfo"`
UploadURL string `json:"uploadUrl"`
LogFileCount uint32 `json:"logFileCount"`
Anonymize bool `json:"anonymize"`
// AnonymizeLevel is "default" or "strict"; strict also anonymizes
// private IP ranges, peer names, and WireGuard public keys.
AnonymizeLevel string `json:"anonymizeLevel"`
SystemInfo bool `json:"systemInfo"`
UploadURL string `json:"uploadUrl"`
LogFileCount uint32 `json:"logFileCount"`
}
// DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload
@@ -48,11 +51,12 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
return DebugBundleResult{}, err
}
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
Anonymize: p.Anonymize,
SystemInfo: p.SystemInfo,
UploadURL: p.UploadURL,
LogFileCount: p.LogFileCount,
CliVersion: version.NetbirdVersion(),
Anonymize: p.Anonymize,
AnonymizeLevel: p.AnonymizeLevel,
SystemInfo: p.SystemInfo,
UploadURL: p.UploadURL,
LogFileCount: p.LogFileCount,
CliVersion: version.NetbirdVersion(),
})
if err != nil {
return DebugBundleResult{}, err

View File

@@ -34,3 +34,7 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
return s.store.SetOnboardingCompleted(done)
}
func (s *Preferences) SetKeepConnectedOnQuit(_ context.Context, keep bool) error {
return s.store.SetKeepConnectedOnQuit(keep)
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/preferences"
"github.com/netbirdio/netbird/client/ui/services"
"github.com/netbirdio/netbird/version"
)
@@ -50,8 +51,9 @@ type TrayServices struct {
WindowManager *services.WindowManager
// Session is bound to authsession directly because the services wrapper
// only re-exposes the React subset.
Session *authsession.Session
Localizer *Localizer
Session *authsession.Session
Localizer *Localizer
Preferences *preferences.Store
}
type Tray struct {
@@ -461,10 +463,12 @@ func (t *Tray) handleQuit() {
t.profileMu.Unlock()
t.svc.DaemonFeed.CancelProfileSwitch()
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
defer cancel()
if err := t.svc.Connection.Down(ctx); err != nil {
log.Errorf("disconnect on quit: %v", err)
if t.svc.Preferences == nil || !t.svc.Preferences.Get().KeepConnectedOnQuit {
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
defer cancel()
if err := t.svc.Connection.Down(ctx); err != nil {
log.Errorf("disconnect on quit: %v", err)
}
}
t.app.Quit()
}

View File

@@ -4,17 +4,26 @@ package main
// bindTrayClick wires the tray icon's left-click handler on Linux.
//
// Both Linux click paths converge on Wails' linuxSystemTray.Activate, which
// fires the registered clickHandler:
// - Real SNI hosts (KDE Plasma, Waybar, GNOME Shell + AppIndicator) invoke
// org.kde.StatusNotifierItem.Activate over D-Bus on left-click.
// - The in-process StatusNotifierWatcher + XEmbed host used on minimal WMs
// (Fluxbox, i3, dwm, OpenBox) maps a Button1 press to that same Activate
// call itself (xembed_host_linux.go), so it routes through the same hook.
// Registering OnClick here therefore covers both paths with one handler — no
// changes to the watcher or XEmbed C code are needed. Left-click now opens the
// main window; right-click still opens the menu via Wails' default
// SecondaryActivate→OpenMenu handler (and the XEmbed GTK popup on minimal WMs).
// Expected behaviour per tray host:
//
// Host Left click Right click
// KDE Plasma, Waybar main window (Activate) menu (host-rendered)
// GNOME Shell + AppIndicator menu only menu only
// Minimal WMs via XEmbed host main window (Activate) XEmbed GTK popup
//
// OnClick fires only on org.kde.StatusNotifierItem.Activate — a real left
// click. KDE/Waybar send it over D-Bus; the in-process XEmbed host
// (xembed_host_linux.go) maps a Button1 press to the same Activate call.
//
// GNOME Shell + AppIndicator never sends Activate: it renders the dbusmenu
// on ANY click and only reports the menu opening via dbusmenu
// Event("opened"). Upstream Wails treated that event as a click, so on GNOME
// both buttons raised the main window on top of the menu, and on KDE/Waybar
// a right click raised it over the freshly opened menu. The netbirdio/wails
// fork (go.mod replace) drops that heuristic: a menu open never fires
// OnClick. On GNOME the main window is reached via the "Open NetBird" menu
// entry; left-click-opens-window is not achievable there anyway, since the
// host always opens the menu itself.
//
// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it
// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's

View File

@@ -27,11 +27,10 @@ const (
finalWarningCountdownSeconds = 120
)
// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal.
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
func (t *Tray) handleSessionExpired() {
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
}
@@ -308,11 +307,7 @@ func (t *Tray) openSessionExtendFlow() {
}
seconds := int(time.Until(deadline).Seconds())
if seconds <= 0 {
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
}
t.app.Event.Emit(services.EventTriggerLogin)
return
}
if t.svc.WindowManager == nil {

View File

@@ -0,0 +1,40 @@
//go:build linux && gtk3 && !(linux && 386)
package main
import (
"errors"
"github.com/godbus/dbus/v5"
)
// The legacy GTK3 / WebKit2GTK 4.1 build (-tags gtk3) drops the in-process
// XEmbed StatusNotifierWatcher entirely. The real implementation
// (xembed_host_linux.go + xembed_tray_linux.c) links GTK4 and uses GTK4-only
// popup-menu APIs that have no drop-in GTK3 equivalent, so rather than port the
// C layer we stub the host out on gtk3 builds. The tray still works on every
// desktop that ships its own StatusNotifierWatcher (KDE, GNOME+AppIndicator,
// Cinnamon/xapp, XFCE, …); only the minimal-WM fallback (Fluxbox/OpenBox/i3/
// dwm/vanilla GNOME) is unavailable on gtk3 packages. See LINUX-TRAY.md.
// xembedHost is a placeholder so the package compiles on gtk3 builds; the real
// type (with X11/GTK4 state) lives in xembed_host_linux.go. It is never
// instantiated here because xembedTrayAvailable always reports false.
type xembedHost struct{}
// run satisfies the call in tray_watcher_linux.go; unreachable on gtk3 because
// newXembedHost never returns a non-nil host.
func (*xembedHost) run() {}
// xembedTrayAvailable always reports false on gtk3 builds, so the watcher probe
// loop in startStatusNotifierWatcher exits immediately and newXembedHost is
// never reached. recenter_linux.go's predicate becomes a harmless no-op too.
func xembedTrayAvailable() bool {
return false
}
// newXembedHost exists only to satisfy the reference in tray_watcher_linux.go;
// it is unreachable because xembedTrayAvailable returns false on gtk3.
func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) {
return nil, errors.New("xembed host unsupported on gtk3 build")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,17 +4,20 @@ package agentnetwork
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/client/rest"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// harnessStartFresh boots a dedicated combined server with its own fresh
// account and registers its teardown on t.
// account and registers its teardown on t. Unlike the shared srv, the fresh
// account has NOT had its agent-network endpoint bootstrapped.
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
t.Helper()
fresh, err := harness.StartCombined(ctx)
@@ -28,16 +31,16 @@ func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, er
return fresh, nil
}
// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
// TestSettingsBootstrapViaPost covers the explicit bootstrap contract on an
// account that has never been bootstrapped: the GET reads as the defaults
// with an empty cluster/subdomain/endpoint, a PUT without a cluster has
// nothing to pin and fails, and a PUT carrying a cluster creates the row and
// pins it immutably. The shared srv cannot provide that starting state (any
// provider-creating test bootstraps it, and test order is deliberately not
// relied on), so this boots a dedicated combined server — the image is
// already built and cached by TestMain's StartCombined, so the extra cost is
// one container start.
func TestSettingsBootstrapViaPut(t *testing.T) {
// with an empty endpoint/proxy_address, a PUT has no row to update and fails,
// and a POST creates the row and assigns the immutable endpoint — labeled
// beneath a proxy address here, with the toggle overrides from the same
// request applied. The shared srv cannot provide that starting state
// (TestMain bootstraps it), so this boots a dedicated combined server — the
// image is already built and cached by TestMain's StartCombined, so the extra
// cost is one container start.
func TestSettingsBootstrapViaPost(t *testing.T) {
ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t)
@@ -47,32 +50,35 @@ func TestSettingsBootstrapViaPut(t *testing.T) {
// as an error and not as a null body.
before, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings on a fresh account must succeed")
assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap")
assert.Empty(t, before.ProxyAddress, "proxy address must be empty before bootstrap")
assert.False(t, before.Dedicated, "an unbootstrapped account has no serving shape")
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
// A PUT without a cluster has nothing to pin the account to.
// A PUT has no row to update yet — bootstrap is the explicit POST.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
EnableLogCollection: true,
EnableLogCollection: true,
AccessLogRetentionDays: 30,
})
requireClientError(t, err)
// A PUT carrying a cluster bootstraps the account and applies the
// mutable fields from the same request. Every toggle is set away from
// its bootstrap default so each assertion can actually fail.
// A POST with a proxy address bootstraps a labeled endpoint and applies
// the toggles from the same request. Every toggle is set away from its
// bootstrap default so each assertion can actually fail.
const cluster = "e2e.bootstrap.netbird.selfhosted"
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr(cluster),
EnableLogCollection: false,
EnablePromptCollection: true,
RedactPii: true,
bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
ProxyAddress: ptr(cluster),
EnableLogCollection: ptr(false),
EnablePromptCollection: ptr(true),
RedactPii: ptr(true),
})
require.NoError(t, err, "bootstrap settings via PUT must succeed")
assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
require.NoError(t, err, "bootstrap settings via POST must succeed")
assert.Equal(t, cluster, bootstrapped.ProxyAddress, "proxy address must be pinned from the request")
require.NotEmpty(t, bootstrapped.Endpoint, "endpoint must be assigned at bootstrap")
assert.True(t, strings.HasSuffix(bootstrapped.Endpoint, "."+cluster),
"labeled endpoint must hang one label beneath the proxy address: %s", bootstrapped.Endpoint)
assert.False(t, bootstrapped.Dedicated, "a labeled pin is not dedicated")
assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
@@ -85,30 +91,176 @@ func TestSettingsBootstrapViaPut(t *testing.T) {
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
// Once bootstrapped, later updates may omit the cluster entirely.
// Once bootstrapped, PUT updates the toggles. The identity fields ride
// along as a required echo of the assigned values; a matching echo is
// accepted and never written.
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Endpoint: bootstrapped.Endpoint,
ProxyAddress: bootstrapped.ProxyAddress,
EnableLogCollection: true,
EnablePromptCollection: false,
RedactPii: true,
AccessLogRetentionDays: 21,
})
require.NoError(t, err, "post-bootstrap update without cluster must succeed")
assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
require.NoError(t, err, "post-bootstrap update must succeed")
require.NotNil(t, persisted.AccessLogRetentionDays)
assert.Equal(t, 21, *persisted.AccessLogRetentionDays, "retention from the update must apply")
assert.Equal(t, bootstrapped.Endpoint, persisted.Endpoint, "endpoint must survive updates untouched")
assert.Equal(t, cluster, persisted.ProxyAddress, "proxy address must survive updates untouched")
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
// The cluster is immutable: a different value is rejected rather than
// silently ignored, and the rejected update must not disturb anything.
// The endpoint is immutable: a PUT carrying a different endpoint is
// rejected, and a second bootstrap is rejected as a conflict. Neither
// rejected write may disturb anything.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr("other.cluster.invalid"),
EnableLogCollection: false,
Endpoint: "other.cluster.invalid",
ProxyAddress: persisted.ProxyAddress,
EnableLogCollection: persisted.EnableLogCollection,
EnablePromptCollection: persisted.EnablePromptCollection,
RedactPii: persisted.RedactPii,
AccessLogRetentionDays: 21,
})
requireClientError(t, err)
_, err = fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
Endpoint: ptr("other.cluster.invalid"),
})
requireClientError(t, err)
final, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after the rejected cluster change must succeed")
assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
require.NoError(t, err, "get settings after the rejected bootstrap must succeed")
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected bootstrap must not change the endpoint")
assert.Equal(t, persisted.ProxyAddress, final.ProxyAddress, "rejected bootstrap must not change the proxy address")
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected bootstrap must not apply its toggles")
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected bootstrap must not apply its toggles")
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected bootstrap must not apply its toggles")
}
// TestSettingsBootstrapSelfAddressed covers the dedicated shape end to end:
// a POST carrying an endpoint claims the hostname verbatim, the proxy address
// equals it, and the pin reads as dedicated — the address-first flow a
// self-hosted operator uses before deploying the proxy that will declare it.
// The tail covers the recovery path the guarded DELETE exists for: with no
// providers and no proxy at the address, the claim can be released and a
// fresh bootstrap succeeds — the fix for a typo'd immutable endpoint.
func TestSettingsBootstrapSelfAddressed(t *testing.T) {
ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t)
require.NoError(t, err, "start dedicated combined server")
created, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
Endpoint: ptr("gw.e2e.netbird.selfhosted"),
})
require.NoError(t, err, "self-addressed bootstrap must succeed")
assert.Equal(t, "gw.e2e.netbird.selfhosted", created.Endpoint, "endpoint must be claimed verbatim")
assert.Equal(t, created.Endpoint, created.ProxyAddress, "self-addressed: proxy address is the endpoint")
assert.True(t, created.Dedicated, "a self-addressed pin is dedicated")
// No providers exist and no proxy declares the address, so both delete
// guards are clear: the delete releases the claim and the account reads
// as unbootstrapped defaults again.
require.NoError(t, fresh.DeleteSettings(ctx), "guarded delete with both guards clear must succeed")
after, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after delete must succeed")
assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped")
// A second delete has nothing to remove.
requireClientError(t, fresh.DeleteSettings(ctx))
// Re-creating is a fresh bootstrap — the released hostname is free to be
// claimed again, or a different one chosen.
recreated, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
Endpoint: ptr("gw2.e2e.netbird.selfhosted"),
})
require.NoError(t, err, "bootstrap after delete must succeed")
assert.Equal(t, "gw2.e2e.netbird.selfhosted", recreated.Endpoint, "the fresh bootstrap claims the new hostname")
}
// TestSettingsConditionalWrites covers the lost-update guard end to end, over
// the same REST client the Terraform provider uses: read the settings, take
// the entity-tag, and have a write refused when the row moved underneath it.
//
// The scenario is the one that motivates the feature. A client reads the
// settings and computes an update. An operator turns PII redaction on in the
// dashboard in the meantime. Without a precondition the client's write puts
// redaction straight back off — no error, no drift warning, a
// compliance-relevant control silently disabled. With one, the write is
// refused and the client can read again.
func TestSettingsConditionalWrites(t *testing.T) {
ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t)
require.NoError(t, err, "start dedicated combined server")
const cluster = "eu.e2e.netbird.selfhosted"
bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{
ProxyAddress: ptr(cluster),
})
require.NoError(t, err, "bootstrap must succeed")
// What the client plans against.
planned, etag, err := fresh.GetSettingsWithETag(ctx)
require.NoError(t, err, "read must succeed")
require.NotEmpty(t, etag, "the read must carry a validator")
assert.Equal(t, bootstrapped.Endpoint, planned.Endpoint)
_, again, err := fresh.GetSettingsWithETag(ctx)
require.NoError(t, err, "second read must succeed")
assert.Equal(t, etag, again, "an unchanged row must read as the same validator")
update := func(redactPii bool, retention int) api.AgentNetworkSettingsRequest {
return api.AgentNetworkSettingsRequest{
Endpoint: planned.Endpoint,
ProxyAddress: planned.ProxyAddress,
EnableLogCollection: true,
EnablePromptCollection: true,
RedactPii: redactPii,
AccessLogRetentionDays: retention,
}
}
// The operator's change, which the planning client never saw.
_, err = fresh.UpdateSettings(ctx, update(true, 21))
require.NoError(t, err, "the intervening update must succeed")
// The client's write, planned against the earlier read, would have turned
// redaction back off. It is refused instead.
_, _, err = fresh.UpdateSettingsIfMatch(ctx, update(false, 7), etag)
require.Error(t, err, "a stale precondition must be refused")
require.True(t, rest.IsPreconditionFailed(err),
"the refusal must be a precondition failure, got: %v", err)
intact, current, err := fresh.GetSettingsWithETag(ctx)
require.NoError(t, err, "read after the refusal must succeed")
assert.True(t, intact.RedactPii, "the refused write must not have turned redaction off")
require.NotNil(t, intact.AccessLogRetentionDays)
assert.Equal(t, 21, *intact.AccessLogRetentionDays, "the refused write must not have changed retention")
assert.NotEqual(t, etag, current, "the validator must have moved with the intervening update")
// Retrying against the current validator goes through, and hands back the
// validator for the write after it.
updated, next, err := fresh.UpdateSettingsIfMatch(ctx, update(true, 7), current)
require.NoError(t, err, "a matching precondition must be honoured")
require.NotNil(t, updated.AccessLogRetentionDays)
assert.Equal(t, 7, *updated.AccessLogRetentionDays, "the conditional write must apply")
assert.NotEmpty(t, next, "the write must return a validator")
assert.NotEqual(t, current, next, "the write must move the validator")
// The delete is conditional too, and refusing a stale one leaves the
// endpoint claimed.
err = fresh.DeleteSettingsIfMatch(ctx, etag)
require.Error(t, err, "a stale precondition must refuse the delete")
require.True(t, rest.IsPreconditionFailed(err),
"the delete must be refused for staleness rather than for a state guard or a server error, got: %v", err)
stillThere, err := fresh.GetSettings(ctx)
require.NoError(t, err, "read after the refused delete must succeed")
assert.Equal(t, planned.Endpoint, stillThere.Endpoint, "the refused delete must leave the endpoint claimed")
require.NoError(t, fresh.DeleteSettingsIfMatch(ctx, next), "a matching precondition must be honoured")
gone, err := fresh.GetSettings(ctx)
require.NoError(t, err, "read after the delete must succeed")
assert.Empty(t, gone.Endpoint, "the row must be gone")
}

View File

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

View File

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

View File

@@ -126,17 +126,64 @@ func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error {
return anDelete(ctx, c, "/api/agent-network/guardrails/"+id)
}
// GetSettings returns the account's agent-network settings row. It exists only
// after the first provider create bootstraps it.
// CreateSettings bootstraps the account's agent-network settings row,
// assigning the immutable endpoint. Exactly one of req.ProxyAddress (labeled
// endpoint beneath that cluster) and req.Endpoint (self-addressed dedicated
// endpoint) must be set; a second bootstrap returns a conflict.
func (c *Combined) CreateSettings(ctx context.Context, req api.AgentNetworkSettingsCreateRequest) (api.AgentNetworkSettings, error) {
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPost, "/api/agent-network/settings", req)
}
// GetSettings returns the account's agent-network settings row. Before the
// CreateSettings bootstrap it reads as the defaults with an empty endpoint.
func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) {
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodGet, "/api/agent-network/settings", nil)
}
// UpdateSettings applies the mutable collection toggles.
// UpdateSettings applies the mutable collection toggles. The request must
// echo the assigned endpoint and proxy address unchanged — the server rejects
// a PUT that tries to change them.
func (c *Combined) UpdateSettings(ctx context.Context, req api.AgentNetworkSettingsRequest) (api.AgentNetworkSettings, error) {
return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPut, "/api/agent-network/settings", req)
}
// DeleteSettings removes the account's settings row, releasing the endpoint.
// Refused while providers exist or a proxy is actively serving the endpoint.
func (c *Combined) DeleteSettings(ctx context.Context) error {
return anDelete(ctx, c, "/api/agent-network/settings")
}
// The conditional-request wrappers go through the typed REST client rather
// than anRequest, so the e2e run exercises the client's own header handling —
// the quoting on the way out and the unquoting on the way back — against a
// real server, which is the path the Terraform provider takes.
// GetSettingsWithETag reads the settings along with the entity-tag that makes
// a following write conditional.
func (c *Combined) GetSettingsWithETag(ctx context.Context) (api.AgentNetworkSettings, string, error) {
settings, etag, err := c.api.AgentNetwork.GetSettingsWithETag(ctx)
if err != nil {
return api.AgentNetworkSettings{}, "", err
}
return *settings, etag, nil
}
// UpdateSettingsIfMatch applies the update only if etag is still current,
// returning the entity-tag of the row it wrote.
func (c *Combined) UpdateSettingsIfMatch(ctx context.Context, req api.AgentNetworkSettingsRequest, etag string) (api.AgentNetworkSettings, string, error) {
settings, newETag, err := c.api.AgentNetwork.UpdateSettingsIfMatch(ctx, req, etag)
if err != nil {
return api.AgentNetworkSettings{}, "", err
}
return *settings, newETag, nil
}
// DeleteSettingsIfMatch deletes the settings row only if etag is still
// current.
func (c *Combined) DeleteSettingsIfMatch(ctx context.Context, etag string) error {
return c.api.AgentNetwork.DeleteSettingsIfMatch(ctx, etag)
}
// ListConsumption returns the account's consumption rows (possibly empty).
func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) {
return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil)

5
go.mod
View File

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

11
go.sum
View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -92,6 +92,13 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
}
func (f *agentNetworkHandlerFixture) do(t *testing.T, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
return f.doWithHeaders(t, method, path, body, nil)
}
// doWithHeaders is do with request headers, for the cases where the header is
// the thing under test (conditional requests).
func (f *agentNetworkHandlerFixture) doWithHeaders(t *testing.T, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != "" {
@@ -101,6 +108,9 @@ func (f *agentNetworkHandlerFixture) do(t *testing.T, method, path, body string)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
for name, value := range headers {
req.Header.Set(name, value)
}
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: testUserID,
AccountId: testAccountID,

View File

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

View File

@@ -12,18 +12,68 @@ import (
"github.com/netbirdio/netbird/shared/management/http/util"
)
// addSettingsEndpoints registers the Agent Network settings routes. The
// settings row is bootstrapped server-side on first provider create or on the
// first PUT carrying a cluster; GET reads it and PUT applies a partial update
// of the mutable collection toggles (cluster/subdomain stay immutable).
// addSettingsEndpoints registers the Agent Network settings routes. POST
// bootstraps the settings row, assigning the account's immutable endpoint;
// GET reads it (defaults with an empty endpoint before bootstrap); PUT
// carries every field, replacing the mutable collection toggles and rejecting
// any change to the identity fields; DELETE removes the row — guarded so it
// stays a bootstrap-repair operation — releasing the endpoint for a fresh
// bootstrap.
func (h *handler) addSettingsEndpoints(router *mux.Router) {
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
router.HandleFunc("/agent-network/settings", h.createSettings).Methods("POST", "OPTIONS")
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
router.HandleFunc("/agent-network/settings", h.deleteSettings).Methods("DELETE", "OPTIONS")
}
// createSettings bootstraps the account's settings row. Exactly one of
// proxy_address (labeled endpoint; the server allocates the label) and
// endpoint (self-addressed, claimed verbatim) must be provided; optional
// collection toggles ride along with defaults for omitted fields.
func (h *handler) createSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
var req api.AgentNetworkSettingsCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
return
}
settings := types.DefaultSettings(userAuth.AccountId)
settings.FromAPICreateRequest(&req)
proxyAddress := ""
if req.ProxyAddress != nil {
proxyAddress = *req.ProxyAddress
}
endpoint := ""
if req.Endpoint != nil {
endpoint = *req.Endpoint
}
created, err := h.manager.CreateSettings(r.Context(), userAuth.UserId, settings, proxyAddress, endpoint)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
// Emitting the validator here lets a client that just bootstrapped issue a
// conditional PUT without an intervening GET.
util.SetETag(w, created.ETag())
util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
}
// updateSettings replaces the mutable settings fields on the account's row.
// A request carrying a cluster bootstraps the row when the account doesn't
// have one yet.
//
// An If-Match header makes the update conditional: it is honoured against the
// stored row inside the write's transaction, and a stale validator is refused
// with 412 rather than overwriting what changed since the client read. Omitting
// the header keeps the pre-existing last-write-wins behaviour.
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -40,14 +90,38 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
settings := &types.Settings{AccountID: userAuth.AccountId}
settings.FromAPIRequest(&req)
updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings)
updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings, util.IfMatch(r))
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.SetETag(w, updated.ETag())
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
}
// deleteSettings removes the account's settings row, releasing the endpoint.
// The manager refuses (412) while providers exist or a proxy is actively
// serving the endpoint; a later POST bootstraps fresh, allocating a new
// endpoint.
//
// An If-Match header makes the delete conditional, and is worth sending here
// even more than on update: both existing guards are about state rather than
// staleness, so nothing else stops a client from deleting a row that was
// replaced since it read one.
func (h *handler) deleteSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
if err := h.manager.DeleteSettings(r.Context(), userAuth.AccountId, userAuth.UserId, util.IfMatch(r)); err != nil {
util.WriteError(r.Context(), err, w)
return
}
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
}
// getSettings returns the account's agent-network settings. Accounts that
// haven't been bootstrapped yet read as the defaults with an empty cluster,
// subdomain and endpoint; the manager synthesises that view.
@@ -63,5 +137,9 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
util.WriteError(r.Context(), err, w)
return
}
// The pre-bootstrap defaults are a representation like any other and carry
// a validator too, so an If-Match taken before bootstrap cannot silently
// match the row that appeared since.
util.SetETag(w, settings.ETag())
util.WriteJSONObject(r.Context(), w, settings.ToAPIResponse())
}

View File

@@ -1,20 +1,25 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read
// convention shared with the account and DNS settings endpoints: settings
// always read as a JSON object. Before bootstrap that object carries the
// defaults with an empty cluster/subdomain/endpoint (the "not bootstrapped"
// defaults with an empty endpoint/proxy_address (the "not bootstrapped"
// signal) and no timestamps — never a 404 and never the legacy null body.
func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
@@ -27,9 +32,9 @@ func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Empty(t, got.Cluster, "cluster must be empty until bootstrapped")
assert.Empty(t, got.Subdomain, "subdomain must be empty until bootstrapped")
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped, not a bare dot")
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped")
assert.Empty(t, got.ProxyAddress, "proxy address must be empty until bootstrapped")
assert.False(t, got.Dedicated, "an unbootstrapped account has no serving shape")
assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off")
assert.False(t, got.RedactPii, "defaults must show redaction off")
@@ -39,62 +44,149 @@ func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists")
}
// TestSettingsHandler_PutBootstrapsWithCluster covers the settings-first
// bootstrap path: a PUT carrying a cluster on an unbootstrapped account
// creates the row (cluster pinned, subdomain assigned) and applies the
// mutable fields from the same request.
func TestSettingsHandler_PutBootstrapsWithCluster(t *testing.T) {
// TestSettingsHandler_PostBootstrapsLabeled covers the labeled bootstrap
// shape: a POST carrying a proxy_address allocates a label beneath it, so the
// endpoint hangs one label under the shared cluster's address and the pin is
// not dedicated. Toggles riding along apply; omitted ones keep defaults.
func TestSettingsHandler_PostBootstrapsLabeled(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "access_log_retention_days": 14}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be pinned from the request")
assert.NotEmpty(t, got.Subdomain, "subdomain must be assigned at bootstrap")
assert.Equal(t, got.Subdomain+".eu.proxy.netbird.io", got.Endpoint, "endpoint must combine subdomain and cluster")
assert.True(t, got.EnableLogCollection, "toggle from the bootstrap request must apply")
assert.Equal(t, "eu.proxy.netbird.io", got.ProxyAddress, "proxy address must be pinned from the request")
require.NotEmpty(t, got.Endpoint, "endpoint must be allocated at bootstrap")
assert.True(t, strings.HasSuffix(got.Endpoint, ".eu.proxy.netbird.io"),
"labeled endpoint must hang off the proxy address: %s", got.Endpoint)
label := strings.TrimSuffix(got.Endpoint, ".eu.proxy.netbird.io")
assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label)
assert.False(t, got.Dedicated, "a labeled pin is not dedicated")
assert.True(t, got.EnableLogCollection, "omitted toggle must keep its default")
assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 30, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
assert.Equal(t, 14, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
assert.NotNil(t, got.CreatedAt, "a persisted row carries timestamps")
// The row is now readable via GET.
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed")
var read api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &read))
assert.Equal(t, got.Endpoint, read.Endpoint, "GET must return the bootstrapped endpoint")
}
// TestSettingsHandler_PutWithoutClusterOnUnbootstrapped pins that a PUT
// without a cluster cannot conjure a settings row out of nothing — there is
// no cluster to pin — and surfaces as 404 like the GET.
func TestSettingsHandler_PutWithoutClusterOnUnbootstrapped(t *testing.T) {
// TestSettingsHandler_PostBootstrapsSelfAddressed covers the dedicated shape:
// a POST carrying an endpoint claims the hostname verbatim, the proxy address
// equals it, and the pin reads as dedicated. The claim is legitimate before
// any proxy declares the address (address-first).
func TestSettingsHandler_PostBootstrapsSelfAddressed(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"endpoint": "Brave-Otter.Gateway.Example.com"}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "brave-otter.gateway.example.com", got.Endpoint,
"endpoint must be claimed verbatim, lowercased")
assert.Equal(t, got.Endpoint, got.ProxyAddress, "self-addressed: the proxy address is the endpoint")
assert.True(t, got.Dedicated, "a self-addressed pin is dedicated")
assert.True(t, got.EnableLogCollection, "omitted toggles must keep their defaults")
}
// TestSettingsHandler_PostRequiresExactlyOneIdentityField pins the request
// contract: proxy_address and endpoint are mutually exclusive and one is
// required — both or neither is a validation error, not a guess.
func TestSettingsHandler_PostRequiresExactlyOneIdentityField(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{}`)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"empty POST must be rejected: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "endpoint": "brave-otter.gateway.example.com"}`)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"POST with both identity fields must be rejected: got %d body=%s", rec.Code, rec.Body.String())
}
// TestSettingsHandler_PostRejectsMalformedHostnames pins per-write input
// validation: shapes canonicalization cannot repair — trailing dots, embedded
// whitespace, empty labels — are rejected with a validation error instead of
// landing in an immutable column.
func TestSettingsHandler_PostRejectsMalformedHostnames(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
for name, body := range map[string]string{
"trailing dot": `{"endpoint": "gateway.example.com."}`,
"leading dot": `{"endpoint": ".gateway.example.com"}`,
"inner whitespace": `{"endpoint": "gate way.example.com"}`,
"empty label": `{"proxy_address": "eu..proxy.netbird.io"}`,
} {
rec := f.do(t, http.MethodPost, "/agent-network/settings", body)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String())
}
}
// TestSettingsHandler_PostConflictsOnSecondBootstrap pins that bootstrap is a
// one-time create: a second POST returns 409 and leaves the row untouched.
func TestSettingsHandler_PostConflictsOnSecondBootstrap(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
require.Equal(t, http.StatusOK, rec.Code, "first bootstrap must succeed: %s", rec.Body.String())
var first api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &first))
rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "us.proxy.netbird.io"}`)
assert.Equal(t, http.StatusConflict, rec.Code,
"second bootstrap must 409: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, first.Endpoint, got.Endpoint, "the original endpoint must survive the rejected bootstrap")
assert.Equal(t, first.ProxyAddress, got.ProxyAddress, "the original proxy address must survive")
}
// TestSettingsHandler_PutBeforeBootstrapIs404 pins that a PUT cannot conjure a
// settings row out of nothing — bootstrap is the explicit POST — and the
// error points the caller there.
func TestSettingsHandler_PutBeforeBootstrapIs404(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`)
assert.Equal(t, http.StatusNotFound, rec.Code,
"cluster-less PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "cluster",
"the error must point the caller at the bootstrap paths: %s", rec.Body.String())
"PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "/api/agent-network/settings",
"the error must point the caller at the bootstrap POST: %s", rec.Body.String())
}
// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared
// with the other PUT endpoints: the request replaces every mutable field, so a
// toggle absent from the JSON lands as its zero value rather than being
// preserved. Cluster and subdomain survive untouched.
// with the other PUT endpoints: the request carries every field, replacing the
// mutable ones. The identity fields ride along as a required echo of the
// assigned values — compared, never written — so the endpoint and proxy
// address survive every accepted update.
func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
var before api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 7}`,
before.Endpoint, before.ProxyAddress))
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
@@ -103,35 +195,400 @@ func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
assert.False(t, got.EnablePromptCollection, "sent toggle must apply")
assert.False(t, got.RedactPii, "sent toggle must apply")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 0, *got.AccessLogRetentionDays,
"retention absent from the request must land as the zero value — PUT replaces all mutable fields")
assert.Equal(t, before.Cluster, got.Cluster, "cluster must survive updates untouched")
assert.Equal(t, before.Subdomain, got.Subdomain, "subdomain must survive updates untouched")
assert.Equal(t, 7, *got.AccessLogRetentionDays, "sent retention must apply")
assert.Equal(t, before.Endpoint, got.Endpoint, "endpoint must survive updates untouched")
assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "proxy address must survive updates untouched")
}
// TestSettingsHandler_PutRejectsClusterChange pins cluster immutability: once
// assigned, a differing cluster is rejected as a validation error instead of
// being silently ignored, so callers never observe a value other than the one
// they sent. Echoing the assigned cluster back stays valid, which lets
// declarative clients send their full desired state idempotently.
func TestSettingsHandler_PutRejectsClusterChange(t *testing.T) {
// TestSettingsHandler_PutRejectsChangedIdentity pins the immutability contract:
// the PUT carries the identity fields like every other field, but they are an
// echo — a request carrying a different endpoint or proxy address is rejected
// as a validation error and the row is left untouched. The comparison is
// lenient about casing (the stored values are normalized lowercase), so a
// client replaying a GET response with different casing is not rejected.
func TestSettingsHandler_PutRejectsChangedIdentity(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap PUT must succeed: %s", rec.Body.String())
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
var before api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "us.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"cluster change must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String())
for name, body := range map[string]string{
"changed endpoint": fmt.Sprintf(
`{"endpoint": "other.gateway.example.com", "proxy_address": %q, "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
before.ProxyAddress),
"changed proxy_address": fmt.Sprintf(
`{"endpoint": %q, "proxy_address": "us.proxy.netbird.io", "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
before.Endpoint),
"omitted identity": `{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
} {
rec = f.do(t, http.MethodPut, "/agent-network/settings", body)
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
"%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String())
}
rec = f.do(t, http.MethodPut, "/agent-network/settings",
`{"cluster": "eu.proxy.netbird.io", "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": true}`)
require.Equal(t, http.StatusOK, rec.Code, "echoing the assigned cluster must stay valid: %s", rec.Body.String())
// The rejected updates must not have applied anything — toggles included.
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, before.Endpoint, got.Endpoint, "rejected PUT must not change the endpoint")
assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "rejected PUT must not change the proxy address")
assert.True(t, got.EnablePromptCollection, "rejected PUT must not apply its toggles")
// An uppercased echo of the assigned values still names the same host and
// must be accepted.
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`,
strings.ToUpper(before.Endpoint), strings.ToUpper(before.ProxyAddress)))
assert.Equal(t, http.StatusOK, rec.Code,
"an uppercased identity echo must be accepted: got %d body=%s", rec.Code, rec.Body.String())
}
// TestSettingsHandler_PutOmittedRetentionLandsAsZero documents a residual the
// required-ness of access_log_retention_days does not remove. Marking the field
// required changes the generated client type from *int to int, so a generated
// client cannot omit it — but nothing validates OpenAPI required-ness at
// runtime, so a hand-rolled body without the field still decodes as 0, which
// the API documents as "keep indefinitely".
//
// That is the same latitude the three booleans already have, so it is left
// consistent rather than special-cased. This test exists to make the gap
// explicit: if request validation is ever added, this expectation is what
// changes.
func TestSettingsHandler_PutOmittedRetentionLandsAsZero(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "access_log_retention_days": 14}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
var before api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`,
before.Endpoint, before.ProxyAddress))
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, "eu.proxy.netbird.io", got.Cluster, "cluster must be unchanged")
assert.True(t, got.RedactPii, "toggle sent alongside the echoed cluster must apply")
require.NotNil(t, got.AccessLogRetentionDays)
assert.Equal(t, 0, *got.AccessLogRetentionDays,
"a non-conforming body that omits retention still replaces it with the zero value")
}
// TestSettingsHandler_DeleteBeforeBootstrapIs404 pins that DELETE on an
// account with no settings row is a 404, mirroring the PUT.
func TestSettingsHandler_DeleteBeforeBootstrapIs404(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodDelete, "/agent-network/settings", "")
assert.Equal(t, http.StatusNotFound, rec.Code,
"DELETE on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
}
// TestSettingsHandler_DeleteBlockedByProviders pins the first delete guard:
// while any provider exists for the account, the delete is refused with 412
// and the row survives. Providers route through the endpoint — the guard
// keeps DELETE a bootstrap-repair operation rather than a way to abandon a
// configured gateway.
func TestSettingsHandler_DeleteBlockedByProviders(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
var before api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
f.seedProvider(t, "prov-guard")
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
"delete with a provider present must be refused: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var got api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
assert.Equal(t, before.Endpoint, got.Endpoint, "the refused delete must leave the row intact")
}
// TestSettingsHandler_DeleteBlockedByActiveProxy pins the second delete
// guard: while a proxy is actively serving the endpoint — an active proxy
// row declaring the endpoint hostname as its cluster address, the dedicated
// shape — the delete is refused with 412. A proxy that has disconnected no
// longer blocks: the guard is about a live serving path, not history.
//
// The proxy declares its address with mixed casing on purpose: Connect
// stores the declared address verbatim while the settings row is normalized
// lowercase, and hostnames are case-insensitive, so the guard must match
// across the casing difference rather than be sidestepped by it.
func TestSettingsHandler_DeleteBlockedByActiveProxy(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
const endpoint = "gw.dedicated.example.com"
rec := f.do(t, http.MethodPost, "/agent-network/settings", fmt.Sprintf(`{"endpoint": %q}`, endpoint))
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
now := time.Now()
accountID := testAccountID
proxyRow := &rpproxy.Proxy{
ID: "proxy-guard",
SessionID: "sess-1",
ClusterAddress: "GW.Dedicated.Example.Com",
AccountID: &accountID,
LastSeen: now,
ConnectedAt: &now,
Status: rpproxy.StatusConnected,
}
require.NoError(t, f.store.SaveProxy(context.Background(), proxyRow))
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
"delete with an active proxy at the endpoint must be refused: got %d body=%s", rec.Code, rec.Body.String())
// Once the proxy disconnects it no longer serves the endpoint, so the
// delete goes through.
require.NoError(t, f.store.DisconnectProxy(context.Background(), proxyRow.ID, proxyRow.SessionID))
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
assert.Equal(t, http.StatusOK, rec.Code,
"delete after the proxy disconnected must succeed: got %d body=%s", rec.Code, rec.Body.String())
}
// TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap pins the
// full-reset semantic that gives replace-on-change clients (e.g. Terraform's
// RequiresReplace) a real path: with both guards clear the delete succeeds,
// the account reads as the defaults again, and a fresh bootstrap draws a
// fresh label. The released hostname is not reserved — a fresh draw may even
// legitimately re-pick it — so the assertions check the new row's shape, not
// that the label differs.
func TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code,
"delete with both guards clear must succeed: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var after api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped defaults")
assert.False(t, after.EnablePromptCollection, "the deleted row's toggles must not linger")
rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
require.Equal(t, http.StatusOK, rec.Code, "re-bootstrap after delete must succeed: %s", rec.Body.String())
var second api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &second))
require.NotEmpty(t, second.Endpoint, "the fresh bootstrap must allocate an endpoint")
assert.True(t, strings.HasSuffix(second.Endpoint, ".eu.proxy.netbird.io"),
"the fresh endpoint must hang beneath the requested proxy address: %s", second.Endpoint)
assert.False(t, second.EnablePromptCollection,
"the fresh row must carry bootstrap defaults, not the deleted row's toggles")
assert.NotNil(t, second.CreatedAt, "the fresh row is persisted and carries timestamps")
}
// bootstrapForETag bootstraps a settings row and returns the response body
// alongside the validator the bootstrap emitted, which is what a client would
// carry into its first conditional write.
func bootstrapForETag(t *testing.T, f *agentNetworkHandlerFixture) (api.AgentNetworkSettings, string) {
t.Helper()
rec := f.do(t, http.MethodPost, "/agent-network/settings",
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
var settings api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &settings))
etag := rec.Header().Get("ETag")
require.NotEmpty(t, etag, "bootstrap must emit a validator so a client can PUT without an intervening GET")
return settings, etag
}
// putBody renders a complete settings update — every field, with the identity
// echo the endpoint requires — so the conditional-request tests differ only in
// their headers.
func putBody(settings api.AgentNetworkSettings, redactPii bool, retention int) string {
return fmt.Sprintf(
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": %t, "access_log_retention_days": %d}`,
settings.Endpoint, settings.ProxyAddress, redactPii, retention)
}
// TestSettingsHandler_EmitsETag pins that every read and every write hands the
// client back a validator, quoted as a strong entity-tag. Without one on the
// write responses a client would have to re-GET after every update to stay
// able to make the next one conditional.
func TestSettingsHandler_EmitsETag(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
// The pre-bootstrap defaults are a representation too, and validate like
// one — an If-Match taken here must not match the row that appears later.
rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
defaultsETag := rec.Header().Get("ETag")
assert.NotEmpty(t, defaultsETag, "the unbootstrapped view must carry a validator")
settings, bootstrapETag := bootstrapForETag(t, f)
assert.Regexp(t, `^"[0-9a-f]+"$`, bootstrapETag, "the validator must be a quoted strong entity-tag")
assert.NotEqual(t, defaultsETag, bootstrapETag, "bootstrapping must move the validator")
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, bootstrapETag, rec.Header().Get("ETag"),
"reading an unchanged row must derive the same validator the bootstrap returned")
rec = f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, false, 7))
require.Equal(t, http.StatusOK, rec.Code, "update must succeed: %s", rec.Body.String())
assert.NotEqual(t, bootstrapETag, rec.Header().Get("ETag"),
"an update that changed the representation must return a different validator")
}
// TestSettingsHandler_PutIfMatch walks the conditional-update contract. The
// stale case is the one the feature exists for: a client that planned against
// an earlier read must be refused rather than silently reverting whatever
// changed in between — RedactPii above all, where a silent revert turns a
// compliance control off with no error and no drift warning.
func TestSettingsHandler_PutIfMatch(t *testing.T) {
t.Run("matching validator succeeds", func(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
settings, etag := bootstrapForETag(t, f)
rec := f.doWithHeaders(t, http.MethodPut, "/agent-network/settings",
putBody(settings, false, 7), map[string]string{"If-Match": etag})
require.Equal(t, http.StatusOK, rec.Code,
"a matching precondition must be honoured: got %d body=%s", rec.Code, rec.Body.String())
assert.NotEqual(t, etag, rec.Header().Get("ETag"),
"the response must carry the new validator, not the one that was matched")
})
t.Run("stale validator is refused and changes nothing", func(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
settings, stale := bootstrapForETag(t, f)
// Someone else writes in between — the dashboard operator enabling
// something the planning client never saw.
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
var intervened api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &intervened))
rec = f.doWithHeaders(t, http.MethodPut, "/agent-network/settings",
putBody(settings, false, 7), map[string]string{"If-Match": stale})
require.Equal(t, http.StatusPreconditionFailed, rec.Code,
"a stale precondition must be refused: got %d body=%s", rec.Code, rec.Body.String())
// Asserting the state, not just the status: a partial write would pass
// a status-only check.
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var after api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
assert.Equal(t, intervened, after, "the refused update must leave the row byte-identical")
})
t.Run("star matches the existing row", func(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
settings, _ := bootstrapForETag(t, f)
rec := f.doWithHeaders(t, http.MethodPut, "/agent-network/settings",
putBody(settings, false, 7), map[string]string{"If-Match": "*"})
assert.Equal(t, http.StatusOK, rec.Code,
"* must match any current representation: got %d body=%s", rec.Code, rec.Body.String())
})
t.Run("no precondition still succeeds", func(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
settings, _ := bootstrapForETag(t, f)
// The back-compatibility guarantee: clients that predate conditional
// requests — the dashboard among them — keep last-write-wins.
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, false, 7))
assert.Equal(t, http.StatusOK, rec.Code,
"an unconditional update must keep working: got %d body=%s", rec.Code, rec.Body.String())
})
t.Run("precondition is checked before the immutability echo", func(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
settings, stale := bootstrapForETag(t, f)
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
// A client stale enough to hold an old validator may be stale in its
// identity echo too. Answering 412 tells it the useful thing — go and
// read again — where 422 would send it hunting an immutability bug.
body := fmt.Sprintf(
`{"endpoint": "other.gateway.example.com", "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 7}`,
settings.ProxyAddress)
rec = f.doWithHeaders(t, http.MethodPut, "/agent-network/settings", body,
map[string]string{"If-Match": stale})
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
"staleness must be reported ahead of the identity mismatch: got %d body=%s", rec.Code, rec.Body.String())
})
}
// TestSettingsHandler_DeleteIfMatch covers the conditional delete, which
// carries more weight than the conditional update: both existing delete guards
// are about state — no providers, no serving proxy — so nothing else stops a
// client from deleting a row that was replaced since it read one.
func TestSettingsHandler_DeleteIfMatch(t *testing.T) {
t.Run("stale validator is refused and the row survives", func(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
settings, stale := bootstrapForETag(t, f)
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
rec = f.doWithHeaders(t, http.MethodDelete, "/agent-network/settings", "",
map[string]string{"If-Match": stale})
require.Equal(t, http.StatusPreconditionFailed, rec.Code,
"a stale precondition must refuse the delete: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var after api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
assert.Equal(t, settings.Endpoint, after.Endpoint, "the refused delete must leave the row in place")
})
t.Run("matching validator deletes", func(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
_, etag := bootstrapForETag(t, f)
rec := f.doWithHeaders(t, http.MethodDelete, "/agent-network/settings", "",
map[string]string{"If-Match": etag})
require.Equal(t, http.StatusOK, rec.Code,
"a matching precondition must be honoured: got %d body=%s", rec.Code, rec.Body.String())
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
require.Equal(t, http.StatusOK, rec.Code)
var after api.AgentNetworkSettings
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
assert.Empty(t, after.Endpoint, "the row must be gone")
})
t.Run("precondition is checked before the state guards", func(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
settings, stale := bootstrapForETag(t, f)
rec := f.do(t, http.MethodPut, "/agent-network/settings", putBody(settings, true, 21))
require.Equal(t, http.StatusOK, rec.Code, "the intervening update must succeed: %s", rec.Body.String())
f.seedProvider(t, "prov-precondition")
// Both refusals are 412, so the status cannot tell them apart — the
// message must, or a stale client is sent to delete providers it may
// not even know about.
rec = f.doWithHeaders(t, http.MethodDelete, "/agent-network/settings", "",
map[string]string{"If-Match": stale})
require.Equal(t, http.StatusPreconditionFailed, rec.Code, "the delete must be refused: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), "if-match",
"staleness must be reported ahead of the provider guard: %s", rec.Body.String())
})
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,134 +0,0 @@
package agentnetwork
import (
"context"
"runtime"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
nbtypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
// so tests can grant the provider permission while denying (or never
// expecting) the settings one.
type bootstrapFixture struct {
manager Manager
store store.Store
perms *permissions.MockManager
}
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("sqlite store not properly supported on Windows yet")
}
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
require.NoError(t, err, "test store setup must succeed")
t.Cleanup(cleanUp)
ctrl := gomock.NewController(t)
perms := permissions.NewMockManager(ctrl)
accounts := account.NewMockManager(ctrl)
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
return &bootstrapFixture{
manager: NewManager(st, perms, accounts, nil),
store: st,
perms: perms,
}
}
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
f.perms.EXPECT().
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
Return(allowed, context.Background(), nil)
}
func newBootstrapProvider(accountID string) *types.Provider {
p := types.NewProvider(accountID)
p.Name = "openai"
p.UpstreamURL = "https://api.openai.com"
p.APIKey = "sk-test"
p.Enabled = true
return p
}
// TestCreateProviderBootstrapRequiresSettingsPermission pins the gate on the
// one-time settings bootstrap: creating the first provider with a
// bootstrap_cluster pins the account's cluster and subdomain, which is a
// settings write and must not ride on the providers permission alone.
func TestCreateProviderBootstrapRequiresSettingsPermission(t *testing.T) {
ctx := context.Background()
t.Run("denied without settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.Error(t, err, "bootstrap without settings permission must fail")
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
providers, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err)
assert.Empty(t, providers, "provider must not be persisted when bootstrap is denied")
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
})
t.Run("allowed with settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
created, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.NoError(t, err, "bootstrap with both permissions must succeed")
require.NotNil(t, created)
settings, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err, "bootstrap must create the settings row")
assert.Equal(t, "cluster1.example.com", settings.Cluster, "settings should pin the bootstrap cluster")
})
t.Run("existing settings need no settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
require.NoError(t, f.store.SaveAgentNetworkSettings(ctx, &types.Settings{
AccountID: "account1",
Cluster: "cluster1.example.com",
Subdomain: "existing",
}), "pre-existing settings row setup must succeed")
// Only the providers permission may be consulted: gomock fails the
// test on any unexpected settings-permission call.
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "cluster1.example.com")
require.NoError(t, err, "create with existing settings must not require the settings permission")
})
t.Run("no bootstrap cluster needs no settings permission", func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
_, err := f.manager.CreateProvider(ctx, "user1", newBootstrapProvider("account1"), "")
require.NoError(t, err, "create without bootstrap must not require the settings permission")
})
}

View File

@@ -10,6 +10,17 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
)
// syntheticMapping pairs a synthesised proxy mapping with the address of the
// proxy that serves it. The cluster is recorded rather than derived from the
// mapping's domain: ProxyMapping does not carry it, and the previous derivation
// -- everything after the first DNS label -- is wrong whenever the service's
// domain is not one label under its proxy's address, which silently addressed
// updates to a cluster no proxy declares.
type syntheticMapping struct {
mapping *proto.ProxyMapping
cluster string
}
// reconcile recomputes the synthesised reverse-proxy services for an
// account, diffs them against the previously-synthesised set in the
// in-memory cache, and emits Create / Update / Delete proxy mappings
@@ -45,18 +56,21 @@ func (m *managerImpl) reconcile(ctx context.Context, accountID string) {
}
oidcCfg := m.proxyController.GetOIDCValidationConfig()
current := make(map[string]*proto.ProxyMapping, len(services))
current := make(map[string]syntheticMapping, len(services))
for _, svc := range services {
if svc == nil || svc.ID == "" {
continue
}
current[svc.ID] = svc.ToProtoMapping(rpservice.Update, "", oidcCfg)
current[svc.ID] = syntheticMapping{
mapping: svc.ToProtoMapping(rpservice.Update, "", oidcCfg),
cluster: svc.ProxyCluster,
}
}
m.reconcileMu.Lock()
previous := m.reconcileCache[accountID]
if previous == nil {
previous = make(map[string]*proto.ProxyMapping)
previous = make(map[string]syntheticMapping)
}
creates, updates, deletes := diffMappings(previous, current)
@@ -67,34 +81,36 @@ func (m *managerImpl) reconcile(ctx context.Context, accountID string) {
}
m.reconcileMu.Unlock()
for _, mapping := range creates {
mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping))
for _, entry := range creates {
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
}
for _, mapping := range updates {
mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping))
for _, entry := range updates {
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
}
for _, mapping := range deletes {
mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping))
for _, entry := range deletes {
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
}
}
// diffMappings classifies the previous→current transition for a
// single account into Create / Update / Delete sets.
// diffMappings classifies the previous→current transition for a single
// account into Create / Update / Delete sets.
//
// Cluster moves (current.cluster != previous.cluster) are surfaced as
// a Delete on the old cluster + Create on the new — handled by
// emitting both a delete (on previous mapping) and a create (on the
// current mapping) for that service ID.
func diffMappings(previous, current map[string]*proto.ProxyMapping) (creates, updates, deletes []*proto.ProxyMapping) {
// A change of serving proxy for the same service ID is surfaced as a Delete
// addressed to the old proxy plus a Create addressed to the new one, so the
// mapping actually moves. Comparing the recorded cluster is what makes that
// detectable: with a placement-free endpoint the mapping's domain is identical
// before and after the move, so nothing about the mapping itself reveals it.
func diffMappings(previous, current map[string]syntheticMapping) (creates, updates, deletes []syntheticMapping) {
for id, cur := range current {
prev, existed := previous[id]
switch {
case !existed:
creates = append(creates, cur)
case prev.GetDomain() == "" || cur.GetAccountId() == prev.GetAccountId() && currentClusterChanged(prev, cur):
case prev.mapping.GetDomain() == "" ||
cur.mapping.GetAccountId() == prev.mapping.GetAccountId() && prev.cluster != cur.cluster:
deletes = append(deletes, prev)
creates = append(creates, cur)
default:
@@ -108,24 +124,3 @@ func diffMappings(previous, current map[string]*proto.ProxyMapping) (creates, up
}
return creates, updates, deletes
}
func currentClusterChanged(prev, cur *proto.ProxyMapping) bool {
return clusterFromMapping(prev) != clusterFromMapping(cur)
}
// clusterFromMapping returns the cluster the mapping should be sent
// to. ProxyMapping doesn't carry the cluster directly, so we rely on
// the synthesised service's domain (`<slug>.<cluster>`) and split on
// the first '.'.
func clusterFromMapping(m *proto.ProxyMapping) string {
if m == nil {
return ""
}
domain := m.GetDomain()
for i := 0; i < len(domain); i++ {
if domain[i] == '.' {
return domain[i+1:]
}
}
return ""
}

View File

@@ -21,7 +21,7 @@ func newReconcileMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *stor
return &managerImpl{
store: mockStore,
proxyController: mockProxy,
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
reconcileCache: make(map[string]map[string]syntheticMapping),
}, mockStore, mockProxy
}
@@ -52,9 +52,9 @@ func newReconcileTestPolicy(providerID, sourceGroupID string) *types.Policy {
func newReconcileTestSettings() *types.Settings {
return &types.Settings{
AccountID: "acct-1",
Cluster: "eu.proxy.netbird.io",
Subdomain: "violet",
AccountID: "acct-1",
Domain: "violet.eu.proxy.netbird.io",
ProxyAddress: "eu.proxy.netbird.io",
}
}
@@ -196,7 +196,7 @@ func TestReconcile_PolicyRemoved_EmitsDelete(t *testing.T) {
func TestReconcile_NilProxyController_NoOp(t *testing.T) {
ctx := context.Background()
mgr := &managerImpl{
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
reconcileCache: make(map[string]map[string]syntheticMapping),
}
// Must not panic; must not query the store.
mgr.reconcile(ctx, "acct-1")
@@ -212,21 +212,78 @@ func TestReconcile_EmptyAccountID_NoOp(t *testing.T) {
mgr.reconcile(ctx, "")
}
func TestClusterFromMapping(t *testing.T) {
tests := []struct {
name string
domain string
want string
}{
{"simple", "openai.eu.proxy.netbird.io", "eu.proxy.netbird.io"},
{"deeply nested", "a.b.c.d", "b.c.d"},
{"no dot", "openai", ""},
{"empty", "", ""},
// TestDiffMappings_ServingProxyChange — when the proxy serving an account
// changes, the same service ID must be deleted on the old proxy and created on
// the new one. The cluster cannot be recovered from the mapping's domain: with a
// placement-free endpoint the domain does not change at all when the serving
// proxy does, so a domain-derived cluster sees no change and emits a plain
// update, addressed to a proxy that does not exist.
func TestDiffMappings_ServingProxyChange(t *testing.T) {
previous := map[string]syntheticMapping{
"svc-1": {
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "brave-otter.gateway.example.com"},
cluster: "proxy.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := clusterFromMapping(&proto.ProxyMapping{Domain: tt.domain})
assert.Equal(t, tt.want, got)
})
current := map[string]syntheticMapping{
"svc-1": {
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "brave-otter.gateway.example.com"},
cluster: "brave-otter.gateway.example.com",
},
}
creates, updates, deletes := diffMappings(previous, current)
if assert.Len(t, deletes, 1, "the old proxy must be told to drop the mapping") {
assert.Equal(t, "proxy.example.com", deletes[0].cluster)
}
if assert.Len(t, creates, 1, "the new proxy must be told to add it") {
assert.Equal(t, "brave-otter.gateway.example.com", creates[0].cluster)
}
assert.Empty(t, updates, "a serving-proxy move is a delete plus a create, not an update")
}
// TestDiffMappings_UnchangedClusterIsAnUpdate keeps the ordinary path: same
// service, same proxy, changed contents.
func TestDiffMappings_UnchangedClusterIsAnUpdate(t *testing.T) {
previous := map[string]syntheticMapping{
"svc-1": {
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "otter.proxy.example.com"},
cluster: "proxy.example.com",
},
}
current := map[string]syntheticMapping{
"svc-1": {
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "otter.proxy.example.com"},
cluster: "proxy.example.com",
},
}
creates, updates, deletes := diffMappings(previous, current)
assert.Empty(t, creates)
assert.Empty(t, deletes)
if assert.Len(t, updates, 1) {
assert.Equal(t, "proxy.example.com", updates[0].cluster)
}
}
// TestDiffMappings_RemovedServiceIsDeletedOnItsOwnCluster — a service that has
// gone away is deleted on the cluster it was last served by, which is recorded
// rather than re-derived.
func TestDiffMappings_RemovedServiceIsDeletedOnItsOwnCluster(t *testing.T) {
previous := map[string]syntheticMapping{
"svc-1": {
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "brave-otter.gateway.example.com"},
cluster: "brave-otter.gateway.example.com",
},
}
creates, updates, deletes := diffMappings(previous, map[string]syntheticMapping{})
assert.Empty(t, creates)
assert.Empty(t, updates)
if assert.Len(t, deletes, 1) {
assert.Equal(t, "brave-otter.gateway.example.com", deletes[0].cluster)
}
}

View File

@@ -0,0 +1,225 @@
package agentnetwork
import (
"context"
"runtime"
"strings"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
nbtypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
// so tests can grant or deny the settings permission per case.
type bootstrapFixture struct {
manager Manager
store store.Store
perms *permissions.MockManager
}
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("sqlite store not properly supported on Windows yet")
}
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
require.NoError(t, err, "test store setup must succeed")
t.Cleanup(cleanUp)
ctrl := gomock.NewController(t)
perms := permissions.NewMockManager(ctrl)
accounts := account.NewMockManager(ctrl)
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
return &bootstrapFixture{
manager: NewManager(st, perms, accounts, nil),
store: st,
perms: perms,
}
}
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
f.perms.EXPECT().
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
Return(allowed, context.Background(), nil)
}
func (f *bootstrapFixture) createSettings(ctx context.Context, accountID, userID, proxyAddress, endpoint string) (*types.Settings, error) {
return f.manager.CreateSettings(ctx, userID, types.DefaultSettings(accountID), proxyAddress, endpoint)
}
// TestCreateSettingsRequiresPermission pins the gate: bootstrap assigns the
// account's immutable endpoint, a settings write requiring the settings
// Create permission — and a denial leaves no row behind.
func TestCreateSettingsRequiresPermission(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
_, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
require.Error(t, err, "bootstrap without the settings permission must fail")
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
}
// TestCreateSettingsLabeled pins the labeled shape: the server allocates an
// adjective-noun label beneath the proxy address, the pin is not dedicated,
// and the domain records the full endpoint hostname.
func TestCreateSettingsLabeled(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
created, err := f.createSettings(ctx, "account1", "user1", "Cluster1.Example.com", "")
require.NoError(t, err, "labeled bootstrap must succeed")
assert.Equal(t, "cluster1.example.com", created.ProxyAddress, "proxy address must be pinned lowercased")
require.True(t, strings.HasSuffix(created.Domain, ".cluster1.example.com"),
"domain must hang one label beneath the proxy address: %s", created.Domain)
label := strings.TrimSuffix(created.Domain, ".cluster1.example.com")
assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label)
assert.False(t, created.Dedicated(), "a labeled pin is not dedicated")
assert.Equal(t, created.Domain, created.Endpoint(), "the endpoint is the domain column")
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err, "bootstrap must persist the row")
assert.Equal(t, created.Domain, stored.Domain)
assert.Equal(t, created.ProxyAddress, stored.ProxyAddress)
}
// TestCreateSettingsSelfAddressed pins the dedicated shape: the endpoint is
// claimed verbatim (normalized), Domain == ProxyAddress, and the claim
// succeeds with no proxy declaring the address yet (address-first).
func TestCreateSettingsSelfAddressed(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
created, err := f.createSettings(ctx, "account1", "user1", "", "Brave-Otter.GW.Example.com")
require.NoError(t, err, "self-addressed bootstrap must succeed")
assert.Equal(t, "brave-otter.gw.example.com", created.Domain, "endpoint must be claimed lowercased")
assert.Equal(t, created.Domain, created.ProxyAddress, "self-addressed: proxy address is the endpoint")
assert.True(t, created.Dedicated(), "a self-addressed pin is dedicated")
}
// TestCreateSettingsIdentityFieldValidation pins the request contract: exactly
// one of proxyAddress and endpoint, and both must be well-formed hostnames.
func TestCreateSettingsIdentityFieldValidation(t *testing.T) {
ctx := context.Background()
cases := map[string]struct {
proxyAddress string
endpoint string
}{
"neither": {"", ""},
"both": {"cluster1.example.com", "gw.example.com"},
"trailing dot endpoint": {"", "gw.example.com."},
"leading dot endpoint": {"", ".gw.example.com"},
"whitespace inside": {"", "g w.example.com"},
"empty label in parent": {"eu..example.com", ""},
"hyphen-edged label": {"", "-gw.example.com"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
_, err := f.createSettings(ctx, "account1", "user1", tc.proxyAddress, tc.endpoint)
require.Error(t, err, "invalid identity input must be rejected")
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error")
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
})
}
}
// TestCreateSettingsConflictsOnSecondBootstrap pins that bootstrap is a
// one-time create per account: a second call is a conflict, whatever shape it
// asks for, and the original row survives untouched.
func TestCreateSettingsConflictsOnSecondBootstrap(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
first, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
require.NoError(t, err)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
_, err = f.createSettings(ctx, "account1", "user1", "", "other.example.com")
require.Error(t, err, "second bootstrap must fail")
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
assert.Equal(t, status.AlreadyExists, sErr.Type(), "second bootstrap must surface as a conflict")
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err)
assert.Equal(t, first.Domain, stored.Domain, "the original endpoint must survive the rejected bootstrap")
}
// TestCreateSettingsEndpointTaken pins global hostname uniqueness: a hostname
// held by one account cannot be claimed by another, in either direction —
// self-addressed onto self-addressed, or self-addressed onto an allocated
// labeled endpoint.
func TestCreateSettingsEndpointTaken(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
first, err := f.createSettings(ctx, "account1", "user1", "", "gw.example.com")
require.NoError(t, err)
f.expectPermission("account2", "user2", modules.AgentNetworkSettings, operations.Create, true)
_, err = f.createSettings(ctx, "account2", "user2", "", "gw.example.com")
require.Error(t, err, "a taken hostname must be refused")
var sErr *status.Error
require.ErrorAs(t, err, &sErr)
assert.Equal(t, status.AlreadyExists, sErr.Type(), "the refusal must surface as a conflict")
f.expectPermission("account3", "user3", modules.AgentNetworkSettings, operations.Create, true)
_, err = f.createSettings(ctx, "account3", "user3", "", first.Domain)
require.Error(t, err, "claiming another account's endpoint must be refused")
}
// TestCreateProviderHasNoSettingsSideEffects pins the decoupling: provider
// create needs only the providers permission (gomock fails the test on any
// settings-permission call) and never creates a settings row.
func TestCreateProviderHasNoSettingsSideEffects(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
provider := types.NewProvider("account1")
provider.Name = "openai"
provider.UpstreamURL = "https://api.openai.com"
provider.APIKey = "sk-test"
provider.Enabled = true
created, err := f.manager.CreateProvider(ctx, "user1", provider)
require.NoError(t, err, "provider create must succeed on the providers permission alone")
require.NotNil(t, created)
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
assert.Error(t, err, "provider create must not conjure a settings row")
}

View File

@@ -0,0 +1,199 @@
package agentnetwork
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
httputil "github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status"
)
// ifMatch builds the precondition a client sending this validator would
// produce, by going through the same header parse the handler uses rather than
// reaching past it.
func ifMatch(t *testing.T, etag string) *httputil.Precondition {
t.Helper()
r := httptest.NewRequest(http.MethodPut, "/", nil)
r.Header.Set("If-Match", strconv.Quote(etag))
return httputil.IfMatch(r)
}
// updateFor renders a complete update for the given row, echoing the identity
// fields the endpoint requires and setting retention to tell writers apart.
func updateFor(settings *types.Settings, retention int) *types.Settings {
return &types.Settings{
AccountID: settings.AccountID,
Domain: settings.Domain,
ProxyAddress: settings.ProxyAddress,
EnableLogCollection: true,
EnablePromptCollection: true,
RedactPii: true,
AccessLogRetentionDays: retention,
}
}
// TestUpdateSettingsPreconditionSerializesConcurrentWriters is the test the
// design rests on. Two writers start from the same validator and race; exactly
// one may win.
//
// An implementation that compares the validator before opening the write
// transaction passes every sequential test in this suite and fails here: both
// writers read the same row, both find their precondition satisfied, and both
// then write — which is the lost update the feature exists to prevent, merely
// narrowed to a smaller window. Holding the row under LockingStrengthUpdate
// and comparing inside the write's own transaction is what makes it a genuine
// compare-and-set.
//
// The test store is sqlite, which serializes writers of its own accord, so
// what this pins directly is the outcome — exactly one success — rather than
// the mechanism. It still has teeth against the check-before-transaction
// shape, whose two reads interleave freely before either write. Running it
// against postgres (NB_STORE_ENGINE_POSTGRES_DSN) exercises real concurrent
// transactions.
func TestUpdateSettingsPreconditionSerializesConcurrentWriters(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
const accountID, userID = "account1", "user1"
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Create, true)
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
created, err := f.createSettings(ctx, accountID, userID, "cluster1.example.com", "")
require.NoError(t, err, "bootstrap must succeed")
// Both writers plan against this one read, as a client that read, computed
// a diff and is about to write the whole object back would.
shared := created.ETag()
// noWrite is a retention value neither writer sends and the API would
// never store, so an assertion that lands on it is a test bug rather than
// a silently satisfied comparison. Zero would not do: the API documents 0
// as "keep indefinitely", so it is a value the row could legitimately hold.
const noWrite = -1
var (
wg sync.WaitGroup
start = make(chan struct{})
errs = make([]error, 2)
wrote = []int{7, 21}
returned = []int{noWrite, noWrite}
)
for i := range 2 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
updated, err := f.manager.UpdateSettings(ctx, userID, updateFor(created, wrote[i]), ifMatch(t, shared))
errs[i] = err
if err == nil {
returned[i] = updated.AccessLogRetentionDays
}
}()
}
close(start)
wg.Wait()
succeeded, winner := 0, noWrite
for i, err := range errs {
if err == nil {
succeeded++
winner = wrote[i]
assert.Equal(t, wrote[i], returned[i], "the winner's response must carry what it sent")
continue
}
assert.Truef(t, isPreconditionFailed(err),
"the losing writer must be refused for staleness, got: %v (writer %d)", err, i)
}
require.Equal(t, 1, succeeded, "exactly one writer may win: %v", errs)
// The row must carry the winner's value and nothing blended.
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
require.NoError(t, err, "the row must survive the race")
assert.Equal(t, winner, stored.AccessLogRetentionDays,
"the stored row must be exactly what the winning writer sent")
assert.NotEqual(t, shared, stored.ETag(), "the surviving row must derive a new validator")
}
// TestUpdateSettingsUnconditionalIgnoresStaleness pins the back-compatibility
// half: without a precondition the manager keeps last-write-wins, which is
// what the dashboard relies on and what any client that predates conditional
// requests does.
func TestUpdateSettingsUnconditionalIgnoresStaleness(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
const accountID, userID = "account1", "user1"
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Create, true)
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
created, err := f.createSettings(ctx, accountID, userID, "cluster1.example.com", "")
require.NoError(t, err, "bootstrap must succeed")
_, err = f.manager.UpdateSettings(ctx, userID, updateFor(created, 21), nil)
require.NoError(t, err, "the first unconditional update must succeed")
// The second writer is working from a read that is now stale, and with no
// precondition it overwrites regardless.
updated, err := f.manager.UpdateSettings(ctx, userID, updateFor(created, 7), nil)
require.NoError(t, err, "an unconditional update must not be refused for staleness")
assert.Equal(t, 7, updated.AccessLogRetentionDays, "last write wins without a precondition")
}
// TestDeleteSettingsPreconditionRefusesStale pins the conditional delete at
// the manager level: a stale validator refuses, and the row is still there
// afterwards. Deletion is the destructive operation and its two other guards
// are about state rather than staleness, so this is the only thing standing
// between a client working from an old read and a released endpoint.
func TestDeleteSettingsPreconditionRefusesStale(t *testing.T) {
ctx := context.Background()
f := newBootstrapFixture(t)
const accountID, userID = "account1", "user1"
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Create, true)
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Update, true)
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Delete, true)
f.expectPermission(accountID, userID, modules.AgentNetworkSettings, operations.Delete, true)
created, err := f.createSettings(ctx, accountID, userID, "cluster1.example.com", "")
require.NoError(t, err, "bootstrap must succeed")
stale := created.ETag()
updated, err := f.manager.UpdateSettings(ctx, userID, updateFor(created, 21), nil)
require.NoError(t, err, "the intervening update must succeed")
err = f.manager.DeleteSettings(ctx, accountID, userID, ifMatch(t, stale))
require.Error(t, err, "a stale precondition must refuse the delete")
assert.True(t, isPreconditionFailed(err), "the refusal must be a precondition failure, got: %v", err)
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
require.NoError(t, err, "the refused delete must leave the row in place")
assert.Equal(t, created.Domain, stored.Domain, "the endpoint must not have been released")
// The validator the intervening update returned is the current one, and
// deleting with it goes through.
require.NoError(t, f.manager.DeleteSettings(ctx, accountID, userID, ifMatch(t, updated.ETag())),
"a matching precondition must be honoured")
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
assert.Error(t, err, "the row must be gone")
}
// isPreconditionFailed reports whether err is the 412-mapped status error.
func isPreconditionFailed(err error) bool {
var sErr *status.Error
return errors.As(err, &sErr) && sErr.Type() == status.PreconditionFailed
}

View File

@@ -89,7 +89,7 @@ func SynthesizeServicesForCluster(ctx context.Context, s store.Store, clusterAdd
return nil, nil
}
settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, clusterAddr)
settingsRows, err := s.GetAgentNetworkSettingsByProxyAddress(ctx, store.LockingStrengthNone, clusterAddr)
if err != nil {
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
}
@@ -116,53 +116,41 @@ func SynthesizeServicesForCluster(ctx context.Context, s store.Store, clusterAdd
}
// SynthesizeServiceForDomain resolves a single agent-network service by its
// public endpoint domain. It lists the (few) settings rows on the domain's
// cluster, matches the one whose endpoint equals the domain, and synthesises
// only that account — avoiding full per-account synthesis for every tenant on
// the cluster, which is what auth/session paths previously paid. Returns nil
// (no error) when no account owns the domain.
// public endpoint domain — a point query on the settings domain unique index,
// then synthesis of just that account. Returns nil (no error) when no account
// owns the domain.
func SynthesizeServiceForDomain(ctx context.Context, s store.Store, domain string) (*rpservice.Service, error) {
domain = strings.TrimSpace(domain)
cluster := clusterFromDomain(domain)
if domain != "" && cluster != "" {
settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, cluster)
if err != nil {
return nil, fmt.Errorf("list agent network settings on cluster: %w", err)
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return nil, nil //nolint:nilnil // optional lookup: no account owns the domain
}
settings, err := s.GetAgentNetworkSettingsByDomain(ctx, store.LockingStrengthNone, domain)
if err != nil {
if isNotFound(err) {
return nil, nil //nolint:nilnil // optional lookup: no account owns the domain
}
for _, settings := range settingsRows {
if settings == nil || settings.Endpoint() != domain {
continue
}
services, serr := SynthesizeServices(ctx, s, settings.AccountID)
if serr != nil {
return nil, serr
}
for _, svc := range services {
if svc != nil && svc.Domain == domain {
return svc, nil
}
}
break
return nil, fmt.Errorf("get agent network settings by domain: %w", err)
}
services, err := SynthesizeServices(ctx, s, settings.AccountID)
if err != nil {
return nil, err
}
for _, svc := range services {
if svc != nil && svc.Domain == domain {
return svc, nil
}
}
return nil, nil //nolint:nilnil // optional lookup: no account owns the domain
}
// clusterFromDomain returns the cluster portion of an endpoint domain (every
// label after the first).
func clusterFromDomain(domain string) string {
if i := strings.IndexByte(domain, '.'); i >= 0 {
return domain[i+1:]
}
return ""
}
// SynthesizeServices builds the in-memory reverse-proxy service that
// fronts the account's agent-network gateway. Returns nil when the
// account has no settings row, no enabled providers, or no enabled
// policies — in any of those cases there's nothing useful to expose.
//
// One service per (account, settings.Cluster) is emitted. The router
// One service per (account, settings.ProxyAddress) is emitted. The router
// middleware encodes a denormalised model→provider routing table
// (auth headers + decrypted API keys baked in); the policy_check
// middleware encodes per-provider authorised group IDs derived from
@@ -175,7 +163,7 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([
if err != nil {
return nil, err
}
if !ok || strings.TrimSpace(settings.Cluster) == "" {
if !ok || strings.TrimSpace(settings.ProxyAddress) == "" {
return nil, nil
}
@@ -934,7 +922,7 @@ func buildAccountService(
middlewares []rpservice.MiddlewareConfig,
sessionPriv, sessionPub string,
) *rpservice.Service {
cluster := settings.Cluster
cluster := settings.ProxyAddress
domain := settings.Endpoint()
serviceID := SynthesizedServiceIDPrefix + accountID

View File

@@ -147,7 +147,7 @@ func TestReconcile_RealStore_PushesPrivateAfterStatusToggle(t *testing.T) {
store: s,
accountManager: noopAccountManager{},
proxyController: ctrl,
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
reconcileCache: make(map[string]map[string]syntheticMapping),
}
m.reconcile(ctx, testAccountID) // initial, provider enabled

View File

@@ -19,15 +19,14 @@ import (
const (
testAccountID = "acct-1"
testCluster = "eu.proxy.netbird.io"
testSubdomain = "violet"
testEndpoint = "violet.eu.proxy.netbird.io"
)
func newSynthTestSettings() *types.Settings {
return &types.Settings{
AccountID: testAccountID,
Cluster: testCluster,
Subdomain: testSubdomain,
AccountID: testAccountID,
Domain: testEndpoint,
ProxyAddress: testCluster,
}
}

View File

@@ -1,6 +1,9 @@
package types
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
@@ -12,13 +15,23 @@ import (
// the long-term aggregate and are retained independently.
const DefaultAccessLogRetentionDays = 30
// Settings is the per-account agent-network configuration row. One
// row per account. Cluster + Subdomain are immutable once written and
// produce the public endpoint agents call (`<subdomain>.<cluster>`).
// Settings is the per-account agent-network configuration row. One row per
// account. Domain and ProxyAddress are assigned at bootstrap and immutable
// thereafter; a persisted row is always fully allocated — there is no "row
// exists, endpoint pending" state.
type Settings struct {
AccountID string `gorm:"primaryKey"`
Cluster string
Subdomain string `gorm:"index:idx_agent_network_settings_cluster_subdomain"`
// Domain is the gateway endpoint hostname agents call. Globally unique
// across accounts. Sized explicitly because MySQL cannot index an
// unbounded TEXT column; 255 covers the RFC 1035 253-octet bound.
Domain string `gorm:"type:varchar(255);uniqueIndex:idx_agent_network_settings_domain"`
// ProxyAddress is the declared cluster address of the proxy serving this
// account's gateway. Either equal to Domain — a proxy dedicated to this
// account, declaring the tenant's own hostname — or Domain's immediate
// parent, with the endpoint one label beneath it on a shared cluster.
ProxyAddress string `gorm:"type:varchar(255);index:idx_agent_network_settings_proxy_address"`
// Account-level collection controls sourced by the synthesizer.
// EnableLogCollection gates the per-request access-log trail and defaults
@@ -45,9 +58,9 @@ func (Settings) TableName() string { return "agent_network_settings" }
// DefaultSettings returns the settings an account observes before its row is
// bootstrapped: log collection on with the default retention, everything else
// off, and no cluster/subdomain assigned yet. Bootstrap persists exactly these
// values plus the assigned cluster and subdomain, so the pre-bootstrap read
// and the freshly bootstrapped row agree.
// off, and no domain or proxy address assigned yet. Bootstrap persists exactly
// these values plus the assigned domain and proxy address, so the
// pre-bootstrap read and the freshly bootstrapped row agree.
func DefaultSettings(accountID string) *Settings {
return &Settings{
AccountID: accountID,
@@ -56,24 +69,83 @@ func DefaultSettings(accountID string) *Settings {
}
}
// Endpoint returns the bare hostname agents reach this account at:
// `<subdomain>.<cluster>`. Empty until both halves are assigned at bootstrap.
func (s *Settings) Endpoint() string {
if s.Cluster == "" || s.Subdomain == "" {
return ""
}
return s.Subdomain + "." + s.Cluster
// etagLength is how much of the hash the validator carries. 16 hex characters
// — 64 bits — is far more than enough to make an accidental collision between
// two representations of one account's settings unreachable, and keeps the
// header short enough to read in a log line.
const etagLength = 16
// ETag returns a strong validator over the settings representation, for
// conditional requests (RFC 9110 If-Match). The value is unquoted; applying
// the quoting is the transport layer's job.
//
// The hash covers an explicit field tuple rather than the marshalled API
// representation: field ordering in the generated API types is not a contract,
// so hashing serialized output would make the validator churn with codegen.
// Two exclusions are deliberate:
//
// - AccountID identifies the resource — it is the URL, not the
// representation. Including it would make the validator differ between
// accounts whose settings are genuinely identical, which no client can
// observe and no precondition needs.
// - UpdatedAt is excluded so that equal representations always yield equal
// validators. A write that changes nothing must not invalidate a
// precondition another client is holding.
//
// Everything else is in, including the identity fields and CreatedAt. A
// validator that covered only the mutable toggles would survive a delete
// followed by a fresh bootstrap onto the same toggle values, and an If-Match
// held across that gap would then authorize a write against what is really a
// different resource. CreatedAt is what distinguishes the re-bootstrapped row.
//
// CreatedAt is hashed at whole-second precision because the validator has to
// agree across a store round-trip. A freshly bootstrapped row derives its
// validator in memory, from a time.Time carrying nanoseconds, while every
// later comparison derives it from a row read back out of the store — and the
// engines truncate: PostgreSQL to microseconds, MySQL DATETIME to whole
// seconds without an fsp. At nanosecond precision the two never agree again,
// so the validator a bootstrap hands out is permanently unusable. Seconds is
// the floor every supported engine preserves. The cost is that a delete and
// re-bootstrap within the same second, onto the same endpoint and the same
// toggles, derives the same validator; a labeled bootstrap draws a fresh
// random label, so that needs a self-addressed endpoint reclaimed inside one
// second.
//
// Adding a field to Settings means deciding whether it belongs here; the
// field-count guard in the tests is what forces that decision.
func (s *Settings) ETag() string {
h := sha256.New()
fmt.Fprintf(h, "%s\x00%s\x00%t\x00%t\x00%t\x00%d\x00%d",
s.Domain,
s.ProxyAddress,
s.EnableLogCollection,
s.EnablePromptCollection,
s.RedactPii,
s.AccessLogRetentionDays,
s.CreatedAt.Unix(),
)
return hex.EncodeToString(h.Sum(nil))[:etagLength]
}
// Endpoint returns the bare hostname agents reach this account at — the
// Domain column. Empty until the row is bootstrapped.
func (s *Settings) Endpoint() string { return s.Domain }
// Dedicated reports whether the account's gateway is served by a proxy
// dedicated to it — the self-addressed shape, where the serving proxy declares
// the endpoint hostname itself. The alternative (labeled) shape has the
// endpoint one label beneath a shared cluster's address.
func (s *Settings) Dedicated() bool { return s.Domain != "" && s.Domain == s.ProxyAddress }
// ToAPIResponse renders the settings as the API representation. The
// timestamps are omitted while zero — a default (not yet bootstrapped) view
// has no persisted row to date.
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
retention := s.AccessLogRetentionDays
resp := &api.AgentNetworkSettings{
Cluster: s.Cluster,
Subdomain: s.Subdomain,
Endpoint: s.Endpoint(),
ProxyAddress: s.ProxyAddress,
Dedicated: s.Dedicated(),
EnableLogCollection: s.EnableLogCollection,
EnablePromptCollection: s.EnablePromptCollection,
RedactPii: s.RedactPii,
@@ -90,19 +162,91 @@ func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
return resp
}
// FromAPIRequest applies the request onto the receiver. The mutable
// collection fields are always replaced with the request values. Cluster
// participates only in bootstrap and the immutability check (see
// Manager.UpdateSettings); Subdomain is server-assigned and never taken
// from a request.
// FromAPIRequest applies the update request onto the receiver: every mutable
// field is replaced with the request value, and the identity fields (Domain,
// ProxyAddress) carry the request's echo of the assigned values. The identity
// fields are never written to the stored row — UpdateSettings compares them
// against it and rejects the request when they differ, so PUT keeps the
// house convention of requiring every field while the endpoint and proxy
// address stay immutable.
//
// Every field is required by the schema, so none is presence-sensitive.
// AccessLogRetentionDays in particular must stay required: the caller receives
// a zero-valued Settings, and UpdateSettings copies each field onto the stored
// row unconditionally, so an omitted value would be written as 0 — which the
// API documents as "keep indefinitely". Making retention optional would
// therefore let a client silently maximise log retention by leaving it out.
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
if req.Cluster != nil {
s.Cluster = strings.TrimSpace(*req.Cluster)
}
s.Domain = req.Endpoint
s.ProxyAddress = req.ProxyAddress
s.EnableLogCollection = req.EnableLogCollection
s.EnablePromptCollection = req.EnablePromptCollection
s.RedactPii = req.RedactPii
s.AccessLogRetentionDays = req.AccessLogRetentionDays
}
// FromAPICreateRequest applies the optional collection toggles of a bootstrap
// request onto the receiver (typically DefaultSettings), leaving defaults in
// place for omitted fields. The identity fields are resolved by the manager
// from the request's proxy_address / endpoint, not copied here.
func (s *Settings) FromAPICreateRequest(req *api.AgentNetworkSettingsCreateRequest) {
if req.EnableLogCollection != nil {
s.EnableLogCollection = *req.EnableLogCollection
}
if req.EnablePromptCollection != nil {
s.EnablePromptCollection = *req.EnablePromptCollection
}
if req.RedactPii != nil {
s.RedactPii = *req.RedactPii
}
if req.AccessLogRetentionDays != nil {
s.AccessLogRetentionDays = *req.AccessLogRetentionDays
}
}
// maxHostnameLength is the RFC 1035 bound on a full domain name.
const maxHostnameLength = 253
// NormalizeHostname lowercases and trims a caller-supplied hostname and
// validates its shape: non-empty DNS labels of letters, digits and inner
// hyphens, joined by single dots, within length bounds. Shapes that
// canonicalization cannot repair — leading/trailing dots, empty labels,
// whitespace inside the name — are rejected rather than guessed at, because
// the value lands in an immutable column.
func NormalizeHostname(raw string) (string, error) {
hostname := strings.ToLower(strings.TrimSpace(raw))
if hostname == "" {
return "", fmt.Errorf("hostname is empty")
}
if len(hostname) > maxHostnameLength {
return "", fmt.Errorf("hostname exceeds %d characters", maxHostnameLength)
}
for _, label := range strings.Split(hostname, ".") {
if err := validateHostnameLabel(label); err != nil {
return "", fmt.Errorf("invalid hostname %q: %w", hostname, err)
}
}
return hostname, nil
}
func validateHostnameLabel(label string) error {
if label == "" {
return fmt.Errorf("empty label (leading, trailing or doubled dot)")
}
if len(label) > 63 {
return fmt.Errorf("label %q exceeds 63 characters", label)
}
if label[0] == '-' || label[len(label)-1] == '-' {
return fmt.Errorf("label %q must not start or end with a hyphen", label)
}
for _, r := range label {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
case r == '-':
default:
return fmt.Errorf("label %q contains invalid character %q", label, r)
}
}
return nil
}

View File

@@ -0,0 +1,184 @@
package types
import (
"reflect"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// etagSettings is a fully populated settings row — every hashed field set to a
// distinctive value — so a mutation test can flip exactly one thing at a time.
// The timestamp carries sub-second precision on purpose: a whole-second value
// would make the precision test below pass without proving anything.
func etagSettings() *Settings {
created := time.Date(2026, 8, 11, 9, 30, 0, 123456789, time.UTC)
return &Settings{
AccountID: "acc-1",
Domain: "cool-otter.eu.proxy.netbird.io",
ProxyAddress: "eu.proxy.netbird.io",
EnableLogCollection: true,
EnablePromptCollection: true,
RedactPii: true,
AccessLogRetentionDays: 30,
CreatedAt: created,
UpdatedAt: created,
}
}
// TestSettings_ETagShape pins the wire shape of the validator: a bare
// lowercase hex string of the documented length, with no quoting — quoting is
// the transport layer's job, and a validator that arrived pre-quoted would be
// double-quoted on the way out.
func TestSettings_ETagShape(t *testing.T) {
etag := etagSettings().ETag()
assert.Len(t, etag, etagLength, "the validator must be exactly etagLength characters")
assert.NotContains(t, etag, `"`, "the derived validator must not carry its own quoting")
for _, r := range etag {
require.Truef(t, (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f'),
"the validator must be lowercase hex, got %q in %q", r, etag)
}
}
// TestSettings_ETagIsStable covers the guarantee every conditional request
// rests on: an unchanged row derives the same validator every time, including
// across a fresh struct built from the same values. A validator that varied
// per derivation would fail every If-Match and make the feature unusable.
func TestSettings_ETagIsStable(t *testing.T) {
s := etagSettings()
first := s.ETag()
assert.Equal(t, first, s.ETag(), "repeated derivation from one value must agree")
assert.Equal(t, first, etagSettings().ETag(), "an equal row must derive an equal validator")
}
// TestSettings_ETagSensitivity is the other half of the contract: every field
// the validator covers must actually move it. The cases are also what makes
// the field-count guard meaningful — a new field that belongs in the tuple but
// is missing from it has no case here, and the guard is what catches that.
//
// The mutations are checked to be pairwise distinct, not merely different from
// the baseline: that is what catches an ambiguous concatenation, where moving
// a character across a field boundary would hash identically without the
// delimiter.
func TestSettings_ETagSensitivity(t *testing.T) {
cases := []struct {
name string
mutate func(*Settings)
}{
{"domain", func(s *Settings) { s.Domain = "brave-otter.eu.proxy.netbird.io" }},
{"proxy address", func(s *Settings) { s.ProxyAddress = "us.proxy.netbird.io" }},
{"log collection", func(s *Settings) { s.EnableLogCollection = false }},
{"prompt collection", func(s *Settings) { s.EnablePromptCollection = false }},
{"redact pii", func(s *Settings) { s.RedactPii = false }},
{"retention", func(s *Settings) { s.AccessLogRetentionDays = 14 }},
{"created at", func(s *Settings) { s.CreatedAt = s.CreatedAt.Add(time.Second) }},
// Moving characters across the Domain/ProxyAddress boundary leaves
// the two fields' concatenation byte-identical, so this case passes
// only because the tuple is delimited.
{"identity boundary shifted", func(s *Settings) {
joined := s.Domain + s.ProxyAddress
split := len(s.Domain) - 3
s.Domain, s.ProxyAddress = joined[:split], joined[split:]
}},
}
baseline := etagSettings().ETag()
seen := map[string]string{"baseline": baseline}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := etagSettings()
tc.mutate(s)
etag := s.ETag()
assert.NotEqual(t, baseline, etag, "changing %s must change the validator", tc.name)
if other, clash := seen[etag]; clash {
t.Fatalf("changing %s derives the same validator as %s (%s) — the field tuple is ambiguous", tc.name, other, etag)
}
seen[etag] = tc.name
})
}
}
// TestSettings_ETagExclusions pins the two deliberate omissions. AccountID is
// the resource's identity rather than its representation. UpdatedAt is left
// out so that a write which changes nothing observable does not invalidate a
// precondition another client is holding — equal representations must always
// derive equal validators.
func TestSettings_ETagExclusions(t *testing.T) {
baseline := etagSettings().ETag()
other := etagSettings()
other.AccountID = "acc-2"
assert.Equal(t, baseline, other.ETag(), "the account id must not reach the validator")
touched := etagSettings()
touched.UpdatedAt = touched.UpdatedAt.Add(time.Hour)
assert.Equal(t, baseline, touched.ETag(), "a write that changed nothing must not move the validator")
}
// TestSettings_ETagSurvivesTimestampTruncation pins the store round-trip the
// validator has to survive. A freshly bootstrapped row derives its validator
// in memory, from a time.Time carrying nanoseconds; every later comparison
// derives it from a row read back out of the store, and the engines truncate
// on the way through — PostgreSQL to microseconds, MySQL DATETIME to whole
// seconds without an fsp. If the hash is sensitive below its coarsest engine's
// precision, the validator a bootstrap hands out never matches again and the
// documented "conditional PUT without an intervening GET" is a permanent 412.
//
// Asserted on the type rather than through a store, so it holds without running
// the suite against every engine. The sqlite test store preserves nanoseconds,
// so a sqlite-only suite cannot observe the truncation at all.
func TestSettings_ETagSurvivesTimestampTruncation(t *testing.T) {
inMemory := etagSettings()
require.NotZero(t, inMemory.CreatedAt.Nanosecond(), "the fixture must carry sub-second precision to prove anything")
for name, truncation := range map[string]time.Duration{
"postgres (microseconds)": time.Microsecond,
"mysql (milliseconds)": time.Millisecond,
"mysql datetime (seconds)": time.Second,
} {
t.Run(name, func(t *testing.T) {
roundTripped := etagSettings()
roundTripped.CreatedAt = roundTripped.CreatedAt.Truncate(truncation)
assert.Equal(t, inMemory.ETag(), roundTripped.ETag(),
"a validator derived before the write must still match one derived after reading the row back")
})
}
}
// TestSettings_ETagOfDefaults covers the pre-bootstrap view, which GET serves
// as a real representation and therefore validates like one. It must derive
// without panicking on the zero CreatedAt, and it must not collide with a
// bootstrapped row — otherwise an If-Match taken before bootstrap would
// authorize a write against the row that appeared since.
func TestSettings_ETagOfDefaults(t *testing.T) {
defaults := DefaultSettings("acc-1").ETag()
assert.Len(t, defaults, etagLength, "the default view must derive a well-formed validator")
assert.NotEqual(t, etagSettings().ETag(), defaults,
"the unbootstrapped view must not validate as a bootstrapped row")
}
// etagFieldCount is the number of fields Settings carries. ETag hashes an
// explicit tuple rather than the struct, so a field added here is silently
// outside the validator until someone decides otherwise — the worst kind of
// gap, because the mechanism looks present and works for every other field.
//
// If this constant needs updating, that is the decision point: either add the
// new field to ETag and give it a case in TestSettings_ETagSensitivity, or
// record here why it stays out.
const etagFieldCount = 9
// TestSettings_ETagFieldCountGuard fails when a field is added to or removed
// from Settings, forcing the question of whether it belongs in the validator.
func TestSettings_ETagFieldCountGuard(t *testing.T) {
assert.Equal(t, etagFieldCount, reflect.TypeFor[Settings]().NumField(),
"Settings gained or lost a field: decide whether it belongs in ETag(), then update etagFieldCount")
}

View File

@@ -2,24 +2,28 @@ package manager
import (
"context"
"errors"
"fmt"
"net"
"strings"
log "github.com/sirupsen/logrus"
agentnetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
nbstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
type store interface {
GetAccount(ctx context.Context, accountID string) (*types.Account, error)
GetAgentNetworkSettings(ctx context.Context, lockStrength nbstore.LockingStrength, accountID string) (*agentnetworkTypes.Settings, error)
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
@@ -311,17 +315,21 @@ func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]s
if err != nil {
return nil, fmt.Errorf("get public cluster addresses: %w", err)
}
reserved, err := m.reservedGatewayAddress(ctx, accountID)
if err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(byopAddresses)+len(publicAddresses))
merged := make([]string, 0, len(byopAddresses)+len(publicAddresses))
for _, addr := range byopAddresses {
if _, ok := seen[addr]; ok {
if _, ok := seen[addr]; ok || addr == reserved {
continue
}
seen[addr] = struct{}{}
merged = append(merged, addr)
}
for _, addr := range publicAddresses {
if _, ok := seen[addr]; ok {
if _, ok := seen[addr]; ok || addr == reserved {
continue
}
seen[addr] = struct{}{}
@@ -330,6 +338,31 @@ func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]s
return merged, nil
}
// reservedGatewayAddress returns the account's agent-network gateway address
// when its settings pin is self-addressed — a proxy dedicated to serving
// exactly the gateway. Dropping that address from the cluster allow list keeps
// it from being offered as a cluster for ordinary services, and because the
// free-domain suffix match is depth-independent, dropping the address rejects
// every name beneath it as well as the bare one. Only the account's own
// gateway address can ever appear in its allow list (another tenant's gateway
// proxy is account-scoped to them), so this single-address exclusion is
// sufficient. Returns "" when the account has no settings row or a labeled
// (shared-cluster) pin.
func (m Manager) reservedGatewayAddress(ctx context.Context, accountID string) (string, error) {
settings, err := m.store.GetAgentNetworkSettings(ctx, nbstore.LockingStrengthNone, accountID)
if err != nil {
var sErr *status.Error
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
return "", nil
}
return "", fmt.Errorf("get agent network settings: %w", err)
}
if settings == nil || !settings.Dedicated() {
return "", nil
}
return settings.ProxyAddress, nil
}
func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) {
bestCluster := ""
bestLen := -1

View File

@@ -7,6 +7,12 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
agentnetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
nbstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
type mockProxyManager struct {
@@ -55,7 +61,7 @@ func TestGetClusterAllowList_BYOPMergedWithPublic(t *testing.T) {
},
}
mgr := Manager{proxyManager: pm}
mgr := Manager{store: &stubStore{}, proxyManager: pm}
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
require.NoError(t, err)
assert.Equal(t, []string{"byop.example.com", "eu.proxy.netbird.io"}, result)
@@ -71,7 +77,7 @@ func TestGetClusterAllowList_DeduplicatesBYOPAndPublic(t *testing.T) {
},
}
mgr := Manager{proxyManager: pm}
mgr := Manager{store: &stubStore{}, proxyManager: pm}
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
require.NoError(t, err)
assert.Equal(t, []string{"shared.example.com", "byop.example.com", "eu.proxy.netbird.io"}, result)
@@ -87,7 +93,7 @@ func TestGetClusterAllowList_NoBYOP_FallbackToShared(t *testing.T) {
},
}
mgr := Manager{proxyManager: pm}
mgr := Manager{store: &stubStore{}, proxyManager: pm}
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
require.NoError(t, err)
assert.Equal(t, []string{"eu.proxy.netbird.io", "us.proxy.netbird.io"}, result)
@@ -100,7 +106,7 @@ func TestGetClusterAllowList_BYOPError_ReturnsError(t *testing.T) {
},
}
mgr := Manager{proxyManager: pm}
mgr := Manager{store: &stubStore{}, proxyManager: pm}
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
require.Error(t, err)
assert.Nil(t, result)
@@ -117,7 +123,7 @@ func TestGetClusterAllowList_PublicError_ReturnsError(t *testing.T) {
},
}
mgr := Manager{proxyManager: pm}
mgr := Manager{store: &stubStore{}, proxyManager: pm}
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
require.Error(t, err)
assert.Nil(t, result)
@@ -134,7 +140,7 @@ func TestGetClusterAllowList_BYOPEmptySlice_FallbackToShared(t *testing.T) {
},
}
mgr := Manager{proxyManager: pm}
mgr := Manager{store: &stubStore{}, proxyManager: pm}
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
require.NoError(t, err)
assert.Equal(t, []string{"eu.proxy.netbird.io"}, result)
@@ -150,8 +156,138 @@ func TestGetClusterAllowList_PublicEmpty_BYOPOnly(t *testing.T) {
},
}
mgr := Manager{proxyManager: pm}
mgr := Manager{store: &stubStore{}, proxyManager: pm}
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
require.NoError(t, err)
assert.Equal(t, []string{"byop.example.com"}, result)
}
// stubStore satisfies the manager's narrow store interface for allow-list
// tests. Only the agent-network settings lookup participates; the default (a
// nil func) reads as "no settings row", the state most accounts are in.
type stubStore struct {
getAgentNetworkSettingsFunc func(ctx context.Context, accountID string) (*agentnetworkTypes.Settings, error)
}
func (s *stubStore) GetAccount(context.Context, string) (*types.Account, error) {
panic("not used in allow-list tests")
}
func (s *stubStore) GetAgentNetworkSettings(ctx context.Context, _ nbstore.LockingStrength, accountID string) (*agentnetworkTypes.Settings, error) {
if s.getAgentNetworkSettingsFunc != nil {
return s.getAgentNetworkSettingsFunc(ctx, accountID)
}
return nil, status.Errorf(status.NotFound, "agent network settings for account %s not found", accountID)
}
func (s *stubStore) GetCustomDomain(context.Context, string, string) (*domain.Domain, error) {
panic("not used in allow-list tests")
}
func (s *stubStore) ListFreeDomains(context.Context, string) ([]string, error) {
panic("not used in allow-list tests")
}
func (s *stubStore) ListCustomDomains(context.Context, string) ([]*domain.Domain, error) {
panic("not used in allow-list tests")
}
func (s *stubStore) CreateCustomDomain(context.Context, string, string, string, bool) (*domain.Domain, error) {
panic("not used in allow-list tests")
}
func (s *stubStore) UpdateCustomDomain(context.Context, string, *domain.Domain) (*domain.Domain, error) {
panic("not used in allow-list tests")
}
func (s *stubStore) DeleteCustomDomain(context.Context, string, string) error {
panic("not used in allow-list tests")
}
// TestGetClusterAllowList_DedicatedGatewayAddressExcluded pins invariant (B)'s
// chokepoint: a self-addressed settings pin reserves the account's gateway
// address, so it is dropped from the allow list — which, because the
// free-domain suffix match is depth-independent, rejects every name beneath
// it as well as the bare one. Other addresses are unaffected.
func TestGetClusterAllowList_DedicatedGatewayAddressExcluded(t *testing.T) {
pm := &mockProxyManager{
getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) {
return []string{"brave-otter.gateway.example.com", "byop.example.com"}, nil
},
getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) {
return []string{"eu.proxy.netbird.io"}, nil
},
}
st := &stubStore{
getAgentNetworkSettingsFunc: func(_ context.Context, accountID string) (*agentnetworkTypes.Settings, error) {
assert.Equal(t, "acc-123", accountID,
"the exclusion must look up the requesting account's own settings")
return &agentnetworkTypes.Settings{
AccountID: accountID,
Domain: "brave-otter.gateway.example.com",
ProxyAddress: "brave-otter.gateway.example.com",
}, nil
},
}
mgr := Manager{store: st, proxyManager: pm}
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
require.NoError(t, err)
assert.Equal(t, []string{"byop.example.com", "eu.proxy.netbird.io"}, result,
"the dedicated gateway address must be reserved from cluster selection")
}
// TestGetClusterAllowList_LabeledPinDoesNotExclude pins the counterpart: a
// labeled pin means the gateway rides on a shared cluster serving ordinary
// services too, so nothing is reserved.
func TestGetClusterAllowList_LabeledPinDoesNotExclude(t *testing.T) {
pm := &mockProxyManager{
getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) {
return []string{"byop.example.com"}, nil
},
getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) {
return []string{"eu.proxy.netbird.io"}, nil
},
}
st := &stubStore{
getAgentNetworkSettingsFunc: func(_ context.Context, accountID string) (*agentnetworkTypes.Settings, error) {
return &agentnetworkTypes.Settings{
AccountID: accountID,
Domain: "violet.eu.proxy.netbird.io",
ProxyAddress: "eu.proxy.netbird.io",
}, nil
},
}
mgr := Manager{store: st, proxyManager: pm}
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
require.NoError(t, err)
assert.Equal(t, []string{"byop.example.com", "eu.proxy.netbird.io"}, result,
"a labeled pin reserves nothing")
}
// TestGetClusterAllowList_SettingsLookupError_ReturnsError pins that a store
// outage is surfaced rather than silently treated as "nothing reserved" —
// failing open here would offer a reserved gateway address for ordinary
// services.
func TestGetClusterAllowList_SettingsLookupError_ReturnsError(t *testing.T) {
pm := &mockProxyManager{
getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) {
return []string{"byop.example.com"}, nil
},
getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) {
return []string{"eu.proxy.netbird.io"}, nil
},
}
st := &stubStore{
getAgentNetworkSettingsFunc: func(_ context.Context, _ string) (*agentnetworkTypes.Settings, error) {
return nil, status.Errorf(status.Internal, "store outage")
},
}
mgr := Manager{store: st, proxyManager: pm}
result, err := mgr.getClusterAllowList(context.Background(), "acc-123")
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "agent network settings")
}

View File

@@ -29,6 +29,7 @@ import (
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
@@ -36,7 +37,6 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/users"
proxyauth "github.com/netbirdio/netbird/proxy/auth"
@@ -61,6 +61,17 @@ type ProxyTokenChecker interface {
IsProxyAccessTokenValid(ctx context.Context, tokenID string) (bool, error)
}
// ProxyConnectAuthorizer authorizes a proxy's claim to the cluster address it
// declares at connect time. Implementations are supplied by integrations; none
// is installed by default, so every well-formed claim is authorized — the
// declared address is otherwise only checked for availability. token is nil
// when the connection carries no proxy access token. A returned status error
// is sent to the proxy unchanged; any other error is wrapped as
// PermissionDenied.
type ProxyConnectAuthorizer interface {
AuthorizeProxyConnect(ctx context.Context, token *types.ProxyAccessToken, proxyID, address string) error
}
// ProxyServiceServer implements the ProxyService gRPC server
// AgentNetworkSynthesizer produces in-memory reverse-proxy services from
// Agent Network provider/policy state for the proxy snapshot path; synthesised
@@ -99,6 +110,9 @@ type ProxyServiceServer struct {
// and the post-flight consumption write (RecordLLMUsage). Optional — when
// nil both RPCs return Unimplemented.
agentNetworkLimits AgentNetworkLimitsService
// connectAuthorizer authorizes address claims at proxy connect time.
// Optional — when nil every well-formed claim is authorized.
connectAuthorizer ProxyConnectAuthorizer
// ProxyController for service updates and cluster management
proxyController proxy.Controller
@@ -262,6 +276,23 @@ func (s *ProxyServiceServer) agentNetworkSynthesizer() AgentNetworkSynthesizer {
return s.agentNetworkSynth
}
// SetProxyConnectAuthorizer wires the connect-time address-claim authorizer.
// Optional — when nil (the default) every well-formed claim is authorized,
// which is the behavior without the hook. The modules layer injects this
// after the proxy server is constructed, like the other setters.
func (s *ProxyServiceServer) SetProxyConnectAuthorizer(authorizer ProxyConnectAuthorizer) {
s.mu.Lock()
s.connectAuthorizer = authorizer
s.mu.Unlock()
}
// proxyConnectAuthorizer returns the connect authorizer under read lock.
func (s *ProxyServiceServer) proxyConnectAuthorizer() ProxyConnectAuthorizer {
s.mu.RLock()
defer s.mu.RUnlock()
return s.connectAuthorizer
}
// CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before
// forwarding an LLM request upstream. Delegates to the agent-network selector,
// which scores applicable policies by remaining headroom and returns the
@@ -446,8 +477,9 @@ func recvSyncInit(stream proto.ProxyService_SyncMappingsServer) (*proto.SyncMapp
return init, nil
}
// validateProxyConnect validates the proxy ID and address, and checks cluster
// address availability for account-scoped tokens.
// validateProxyConnect validates the proxy ID and address, checks cluster
// address availability for account-scoped tokens, and finally consults the
// connect authorizer (when installed) on the address claim.
func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx context.Context) (proxyConnectParams, error) {
if proxyID == "" {
return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy_id is required")
@@ -467,6 +499,19 @@ func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx c
}
}
// The authorizer runs last, outside the account-scoped branch, so it also
// sees management-wide and token-less connects. PermissionDenied keeps an
// authorization rejection distinguishable from the AlreadyExists address
// conflict above in proxy logs.
if authorizer := s.proxyConnectAuthorizer(); authorizer != nil {
if err := authorizer.AuthorizeProxyConnect(ctx, token, proxyID, address); err != nil {
if _, ok := status.FromError(err); ok {
return proxyConnectParams{}, err
}
return proxyConnectParams{}, status.Errorf(codes.PermissionDenied, "proxy connect not authorized: %v", err)
}
}
return proxyConnectParams{proxyID: proxyID, address: address}, nil
}
@@ -1579,9 +1624,62 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL
return verifier, redirectURL, nil
}
// Denied reasons reported to the proxy when access is refused because of the
// account status of the user behind the request.
const (
deniedReasonPendingApproval = "pending_approval"
deniedReasonUserBlocked = "user_blocked"
deniedReasonUserNotFound = "user_not_found"
)
var (
// ErrUserPendingApproval reports a user whose account still awaits approval
// by an administrator and may therefore not hold a proxy session.
ErrUserPendingApproval = errors.New("user pending approval")
// ErrUserBlocked reports a blocked user, who may not hold a proxy session.
ErrUserBlocked = errors.New("user blocked")
errUserUnresolved = errors.New("user could not be resolved")
)
// checkUserStatus reports whether the user's account status permits reverse
// proxy access, returning the denied reason for the proxy access log together
// with the sentinel error callers match on. A user awaiting approval is stored
// as both pending and blocked, so the pending state is reported first: it is
// the one an administrator can act on.
func checkUserStatus(user *types.User) (string, error) {
switch {
case user == nil:
return deniedReasonUserNotFound, errUserUnresolved
case user.PendingApproval:
return deniedReasonPendingApproval, ErrUserPendingApproval
case user.IsBlocked():
return deniedReasonUserBlocked, ErrUserBlocked
default:
return "", nil
}
}
// userStatusDeniedReason returns the denied reason for callers that report a
// decision rather than an error, and an empty string when the user may proceed.
func userStatusDeniedReason(user *types.User) string {
reason, _ := checkUserStatus(user)
return reason
}
// sameAccount reports whether a user belongs to a service's account. An empty
// identifier on either side never matches: two unset accounts must not compare
// equal into a grant.
func sameAccount(userAccountID, serviceAccountID string) bool {
return userAccountID != "" && serviceAccountID != "" && userAccountID == serviceAccountID
}
// GenerateSessionToken creates a signed session JWT for the given domain and
// user. The user's group memberships are embedded in the token so policy-aware
// middlewares on the proxy can authorise without an extra management round-trip.
// A user the store cannot resolve, or whose account is pending approval or
// blocked, gets no token at all, so the browser never receives a session cookie.
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
service, err := s.getServiceByDomain(ctx, domain)
if err != nil {
@@ -1592,25 +1690,37 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
return "", fmt.Errorf("no session key configured for domain: %s", domain)
}
var (
email string
groupIDs []string
groupNames []string
)
if s.usersManager != nil {
user, userGroups, uerr := s.usersManager.GetUserWithGroups(ctx, userID)
if uerr != nil {
log.WithContext(ctx).Debugf("session token mint: lookup user %s: %v", userID, uerr)
} else if user != nil {
email = user.Email
groupIDs, groupNames = pairGroupIDsAndNames(userGroups)
}
if s.usersManager == nil {
return "", errors.New("users manager not configured")
}
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
if err != nil {
return "", fmt.Errorf("get user %s: %w", userID, err)
}
if user == nil {
return "", fmt.Errorf("get user %s: %w", userID, errUserUnresolved)
}
// Bind the OIDC identity to the service's account before signing anything
// with that service's session key. The proxy validates an installed cookie
// locally against the service public key, so a token minted for a user of
// another account would be honoured without a management round-trip.
if !sameAccount(user.AccountID, service.AccountID) {
return "", fmt.Errorf("user %s does not belong to the service account", userID)
}
if _, err := checkUserStatus(user); err != nil {
return "", fmt.Errorf("session token for user %s: %w", userID, err)
}
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
return sessionkey.SignToken(
service.SessionPrivateKey,
userID,
email,
user.Email,
domain,
method,
groupIDs,
@@ -1628,6 +1738,10 @@ func (s *ProxyServiceServer) ValidateUserGroupAccess(ctx context.Context, domain
return fmt.Errorf("user not found: %s", userID)
}
if _, err := checkUserStatus(user); err != nil {
return fmt.Errorf("user %s denied access to domain %s: %w", userID, domain, err)
}
service, err := s.getAccountServiceByDomain(ctx, user.AccountID, domain)
if err != nil {
return err
@@ -1682,10 +1796,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
sessionToken := req.GetSessionToken()
if domain == "" || sessionToken == "" {
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "missing domain or session_token",
}, nil
return deniedSessionResponse("missing domain or session_token"), nil
}
service, err := s.getServiceByDomain(ctx, domain)
@@ -1695,83 +1806,49 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
"error": err.Error(),
}).Debug("ValidateSession: service not found")
//nolint:nilerr
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "service_not_found",
}, nil
return deniedSessionResponse("service_not_found"), nil
}
if err := enforceAccountScope(ctx, service.AccountID); err != nil {
return nil, err
}
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"error": err.Error(),
}).Error("ValidateSession: decode public key")
//nolint:nilerr
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "invalid_service_config",
}, nil
}
userID, _, _, _, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"error": err.Error(),
}).Debug("ValidateSession: invalid session token")
//nolint:nilerr
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "invalid_token",
}, nil
userID, reason := sessionTokenSubject(domain, service, sessionToken)
if reason != "" {
return deniedSessionResponse(reason), nil
}
user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
if err != nil {
if err != nil || user == nil {
log.WithFields(log.Fields{
"domain": domain,
"user_id": userID,
"error": err.Error(),
"error": err,
}).Debug("ValidateSession: user not found")
//nolint:nilerr
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "user_not_found",
}, nil
return deniedSessionResponse(deniedReasonUserNotFound), nil
}
if user.AccountID != service.AccountID {
// A user from another account gets a bare response: none of their identity
// belongs in an answer to a proxy serving a different account.
if !sameAccount(user.AccountID, service.AccountID) {
log.WithFields(log.Fields{
"domain": domain,
"user_id": userID,
"user_account": user.AccountID,
"service_account": service.AccountID,
}).Debug("ValidateSession: user account mismatch")
//nolint:nilerr
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: "account_mismatch",
}, nil
return deniedSessionResponse("account_mismatch"), nil
}
if err := s.checkGroupAccess(service, user); err != nil {
log.WithFields(log.Fields{
"domain": domain,
"user_id": userID,
"error": err.Error(),
}).Debug("ValidateSession: access denied")
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
//nolint:nilerr
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
if reason := s.accountUserDeniedReason(domain, service, user); reason != "" {
return &proto.ValidateSessionResponse{
Valid: false,
UserId: user.Id,
UserEmail: user.Email,
DeniedReason: "not_in_group",
DeniedReason: reason,
PeerGroupIds: groupIDs,
PeerGroupNames: groupNames,
}, nil
@@ -1783,7 +1860,6 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
"email": user.Email,
}).Debug("ValidateSession: access granted")
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
return &proto.ValidateSessionResponse{
Valid: true,
UserId: user.Id,
@@ -1793,6 +1869,66 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
}, nil
}
// deniedSessionResponse builds a denial that carries no identity, for the
// checks that run before a user of this service's account is resolved.
func deniedSessionResponse(reason string) *proto.ValidateSessionResponse {
return &proto.ValidateSessionResponse{
Valid: false,
DeniedReason: reason,
}
}
// sessionTokenSubject verifies the session token against the service's session
// key and returns the user it was minted for, or the reason it cannot be
// trusted.
func sessionTokenSubject(domain string, service *rpservice.Service, sessionToken string) (userID, deniedReason string) {
pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"error": err.Error(),
}).Error("ValidateSession: decode public key")
return "", "invalid_service_config"
}
userID, _, _, _, _, err = proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"error": err.Error(),
}).Debug("ValidateSession: invalid session token")
return "", "invalid_token"
}
return userID, ""
}
// accountUserDeniedReason gates a user of the service's own account, returning
// an empty string when access is granted. Account status comes before group
// membership: a user awaiting approval or blocked has no access regardless of
// the groups they were auto-assigned.
func (s *ProxyServiceServer) accountUserDeniedReason(domain string, service *rpservice.Service, user *types.User) string {
if reason := userStatusDeniedReason(user); reason != "" {
log.WithFields(log.Fields{
"domain": domain,
"user_id": user.Id,
"reason": reason,
}).Debug("ValidateSession: user status denies access")
return reason
}
if err := s.checkGroupAccess(service, user); err != nil {
log.WithFields(log.Fields{
"domain": domain,
"user_id": user.Id,
"error": err.Error(),
}).Debug("ValidateSession: access denied")
return "not_in_group"
}
return ""
}
func (s *ProxyServiceServer) getServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) {
service, err := s.serviceManager.GetServiceByDomain(ctx, domain)
if err == nil {
@@ -1907,7 +2043,20 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
}
groupIDs, groupNames := pairGroupIDsAndNames(peerGroups)
principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer)
owner := s.resolvePeerOwner(ctx, peer, service.AccountID)
principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer, owner)
if reason := peerOwnerDeniedReason(peer, owner); reason != "" {
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "user_id": peer.UserID, "reason": reason}).Debug("ValidateTunnelPeer: owner status denies access")
return &proto.ValidateTunnelPeerResponse{
Valid: false,
UserId: principalID,
UserEmail: displayIdentity,
DeniedReason: reason,
PeerGroupIds: groupIDs,
PeerGroupNames: groupNames,
}, nil
}
if err := checkPeerGroupAccess(service, groupIDs); err != nil {
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied")
@@ -1944,9 +2093,55 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
}, nil
}
// resolvePeerOwner returns the user a peer is linked to, once per request so
// the status gate and the identity resolution below share a single lookup.
// Unlinked peers (machine agents) have no owner. A lookup that fails returns
// nil rather than an error: both callers treat an unresolved owner the same
// way, and neither may trust one it could not read.
func (s *ProxyServiceServer) resolvePeerOwner(ctx context.Context, peer *peer.Peer, accountID string) *types.User {
if peer.UserID == "" {
return nil
}
user, err := s.usersManager.GetUser(ctx, peer.UserID)
if err != nil {
log.WithContext(ctx).Debugf("ValidateTunnelPeer: look up owner %s of peer %s: %v", peer.UserID, peer.ID, err)
return nil
}
// The lookup is by user ID alone, so a peer row pointing outside the
// service's account would otherwise resolve a foreign user. Leave the owner
// unresolved instead: the gate denies it, and neither the response nor the
// minted token carries an identity from another account.
if !sameAccount(user.AccountID, accountID) {
log.WithContext(ctx).Debugf("ValidateTunnelPeer: owner %s of peer %s belongs to another account", peer.UserID, peer.ID)
return nil
}
return user
}
// peerOwnerDeniedReason gates the mesh fast-path on the account status of the
// peer's owning user, so a user blocked after registering a peer loses
// mesh-origin access too. Unlinked peers (machine agents) have no owner to gate
// on and stay first-class callers. An owner the store cannot resolve denies:
// an unavailable lookup must not grant access.
func peerOwnerDeniedReason(peer *peer.Peer, owner *types.User) string {
if peer.UserID == "" {
return ""
}
if owner == nil {
return deniedReasonUserNotFound
}
return userStatusDeniedReason(owner)
}
// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a
// user or peer ID, and peer name or user email.
func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer) (string, string) {
// user or peer ID, and peer name or user email. owner is the already-resolved
// user the peer is linked to, or nil.
func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer, owner *types.User) (string, string) {
// Resolve the principal: when the peer is linked to a user, the human is the
// principal so multiple peers owned by the same user share a single
// identity. Unlinked peers (machine agents) are their own principal keyed on
@@ -1963,10 +2158,10 @@ func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain strin
principalID := peer.UserID
displayIdentity := peer.Name
// Stored column first (cheap, but often empty for OIDC-provisioned users).
if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil {
principalID = user.Id
if user.Email != "" {
displayIdentity = user.Email
if owner != nil {
principalID = owner.Id
if owner.Email != "" {
displayIdentity = owner.Email
}
}
// IdP enrichment wins when available — the stored email column is a

View File

@@ -0,0 +1,168 @@
package grpc
import (
"context"
"errors"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
grpcstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
"github.com/netbirdio/netbird/management/server/types"
)
// capturingAuthorizer records the arguments of the last AuthorizeProxyConnect
// call and returns a fixed error.
type capturingAuthorizer struct {
called int
token *types.ProxyAccessToken
proxyID string
address string
err error
}
func (a *capturingAuthorizer) AuthorizeProxyConnect(_ context.Context, token *types.ProxyAccessToken, proxyID, address string) error {
a.called++
a.token = token
a.proxyID = proxyID
a.address = address
return a.err
}
// authorizerServer builds a ProxyServiceServer whose proxy manager reports
// every cluster address as available, so the authorizer is the only thing
// standing between a claim and success.
func authorizerServer(t *testing.T) *ProxyServiceServer {
t.Helper()
ctrl := gomock.NewController(t)
mgr := proxy.NewMockManager(ctrl)
mgr.EXPECT().IsClusterAddressAvailable(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes()
return &ProxyServiceServer{proxyManager: mgr}
}
// TestValidateProxyConnect_NilAuthorizerUnchanged guards the no-behavior-change
// claim: with no authorizer installed — the OSS default — a well-formed claim
// succeeds and malformed input is rejected exactly as before the hook existed.
func TestValidateProxyConnect_NilAuthorizerUnchanged(t *testing.T) {
s := authorizerServer(t)
params, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
require.NoError(t, err)
assert.Equal(t, "proxy-1", params.proxyID)
assert.Equal(t, "cluster.example.com", params.address)
_, err = s.validateProxyConnect("", "cluster.example.com", scopedCtx("acc-1"))
require.Error(t, err)
st, ok := grpcstatus.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.InvalidArgument, st.Code(), "missing proxy_id must stay InvalidArgument")
_, err = s.validateProxyConnect("proxy-1", "not a hostname", scopedCtx("acc-1"))
require.Error(t, err)
st, ok = grpcstatus.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.InvalidArgument, st.Code(), "invalid address must stay InvalidArgument")
}
// TestValidateProxyConnect_AuthorizerReceivesClaim pins the hook contract: the
// authorizer sees the presented token and the claimed proxy ID and address,
// and an authorized claim proceeds.
func TestValidateProxyConnect_AuthorizerReceivesClaim(t *testing.T) {
s := authorizerServer(t)
auth := &capturingAuthorizer{}
s.SetProxyConnectAuthorizer(auth)
params, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
require.NoError(t, err)
assert.Equal(t, "cluster.example.com", params.address)
require.Equal(t, 1, auth.called, "authorizer must be consulted exactly once per connect")
assert.Equal(t, "proxy-1", auth.proxyID)
assert.Equal(t, "cluster.example.com", auth.address)
require.NotNil(t, auth.token, "the presented token must be handed to the authorizer")
require.NotNil(t, auth.token.AccountID)
assert.Equal(t, "acc-1", *auth.token.AccountID)
}
// TestValidateProxyConnect_PlainErrorBecomesPermissionDenied pins the error
// mapping: a non-status error from the authorizer surfaces as
// PermissionDenied — distinguishable from the AlreadyExists used for address
// conflicts — and the claim does not proceed even though the address itself
// was available.
func TestValidateProxyConnect_PlainErrorBecomesPermissionDenied(t *testing.T) {
s := authorizerServer(t)
s.SetProxyConnectAuthorizer(&capturingAuthorizer{err: errors.New("not the assigned credential")})
_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
require.Error(t, err)
st, ok := grpcstatus.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.PermissionDenied, st.Code())
assert.Contains(t, st.Message(), "not the assigned credential", "the authorizer's reason must survive into the status message")
}
// TestValidateProxyConnect_StatusErrorPassesThrough pins that an authorizer
// which chooses its own status code is not second-guessed.
func TestValidateProxyConnect_StatusErrorPassesThrough(t *testing.T) {
s := authorizerServer(t)
s.SetProxyConnectAuthorizer(&capturingAuthorizer{
err: grpcstatus.Errorf(codes.ResourceExhausted, "try later"),
})
_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
require.Error(t, err)
st, ok := grpcstatus.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.ResourceExhausted, st.Code(), "a status error must pass through unchanged")
assert.Equal(t, "try later", st.Message())
}
// TestValidateProxyConnect_AuthorizerSeesGlobalAndTokenlessConnects pins the
// call-site placement: the authorizer sits outside the account-scoped branch,
// so management-wide tokens (AccountID == nil) and connections without any
// token are also presented to it rather than bypassing policy.
func TestValidateProxyConnect_AuthorizerSeesGlobalAndTokenlessConnects(t *testing.T) {
s := &ProxyServiceServer{} // no proxy manager: neither path may reach the availability check
auth := &capturingAuthorizer{}
s.SetProxyConnectAuthorizer(auth)
_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", globalCtx())
require.NoError(t, err)
require.Equal(t, 1, auth.called, "a management-wide token must still be presented to the authorizer")
require.NotNil(t, auth.token)
assert.Nil(t, auth.token.AccountID)
_, err = s.validateProxyConnect("proxy-1", "cluster.example.com", context.Background())
require.NoError(t, err)
require.Equal(t, 2, auth.called, "a token-less connect must still be presented to the authorizer")
assert.Nil(t, auth.token, "no token in context must surface as a nil token, not a zero value")
}
// TestValidateProxyConnect_AuthorizerRunsLast pins the ordering: input
// validation and the availability check precede policy, so the authorizer is
// never consulted about a claim that is malformed or already rejected.
func TestValidateProxyConnect_AuthorizerRunsLast(t *testing.T) {
ctrl := gomock.NewController(t)
mgr := proxy.NewMockManager(ctrl)
mgr.EXPECT().IsClusterAddressAvailable(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil)
s := &ProxyServiceServer{proxyManager: mgr}
auth := &capturingAuthorizer{}
s.SetProxyConnectAuthorizer(auth)
_, err := s.validateProxyConnect("proxy-1", "not a hostname", scopedCtx("acc-1"))
require.Error(t, err)
assert.Zero(t, auth.called, "a malformed address must be rejected before policy runs")
_, err = s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
require.Error(t, err)
st, ok := grpcstatus.FromError(err)
require.True(t, ok)
assert.Equal(t, codes.AlreadyExists, st.Code(), "an address conflict must keep its own status")
assert.Zero(t, auth.called, "a conflicting address must be rejected before policy runs")
}

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