Compare commits

...

18 Commits

Author SHA1 Message Date
Brad Ison
e493efd532 chore(agentnetwork): fix config grouping, dead code, comments, coverage
- Move the new zone config key into the existing AgentNetwork config
  group (management/internals/server/config/config.go) instead of a
  sibling top-level field, wire modules.go to the new path, add the
  matching field to combined/cmd/config.go's AgentNetworkConfig and its
  mapping (it was previously unreachable in the combined binary), and
  document the key in infrastructure_files/management.json.tmpl and
  combined/config.yaml.example.

- Delete PickUnique and its three tests: Task 5 removed its last
  production caller, leaving it dead exported code with a stale
  words.go comment pointing at it.

- Reword two test comments that referenced our private review process
  instead of stating what the test locks down / why TargetId stays
  pinned to Cluster.

- Add TestBootstrapSettings_NonRetryableErrorFailsImmediately: a
  regression that dropped the isUniqueConstraintError gate and retried
  on every error would leave every existing allocator test green.

- Fix TestSynthesizeServiceForDomain_DegenerateInput's docstring: the
  early-return guard is an optimisation, not what makes "" and
  "localhost" resolve to no service.

- Replace manager.go's allocation-comment archaeology (a deleted
  per-cluster "taken" set, an `accountID[:4]` suffix, "~68 minutes") with
  the actual invariant, and note why each retry attempt gets its own
  transaction (a failed statement poisons the enclosing transaction on
  postgres).

- Collapse the per-service "no matching zone apex" debug log in
  account.go into a single line per call instead of one per skipped
  service.

- Document why idx_agent_network_settings_cluster_subdomain must stay
  on mysql: its index tag is what sizes subdomain as varchar(191)
  rather than longtext, which the new unique index requires.
2026-08-03 23:42:25 +02:00
Brad Ison
91dd9fa239 feat(dns): derive the mesh DNS apex for zone-based endpoints
The zone a private service's synthesized A record hangs under was derived
from the serving proxy's address or from a validated custom domain. A
placement-free endpoint matches neither, so the apex came out empty, the
service was skipped, and the tenant's hostname resolved to nothing -- with no
error logged.

Synthesized services now carry their zone explicitly and it is preferred when
deriving the apex. The field is in-memory only: these services are built per
read and never persisted, and the zone cannot be supplied as a parameter
instead because it is captured per account at allocation time, so a single
current-config value would misclassify any tenant allocated under a previous
one.

A blanket "use the parent of the hostname" fallback was rejected: the same
empty apex also occurs for a service whose domain has no validated entry for
its cluster, and those resolve to nothing deliberately, so a blanket fallback
would turn domain validation into a no-op.
2026-08-03 23:36:12 +02:00
Brad Ison
58d0793870 perf(agentnetwork): resolve endpoints by indexed subdomain lookup
Reverse resolution -- hostname to owning account -- prefiltered candidates by
"strip the first label and treat the rest as a cluster address". For an
endpoint whose parent is a DNS zone that matches no cluster, the prefilter
found nothing and resolution failed for every zone-based tenant.

Both endpoint shapes put the account's label in the first DNS label, and the
label is now globally unique, so one indexed point lookup resolves either
shape. This replaces the prefilter outright rather than adding a fallback
scan, which matters because the lookup runs per request from the
authentication path. A label match is not sufficient on its own -- owning
"brave-otter" does not mean owning "brave-otter.example.com" -- so the
resolved row's endpoint is still compared against the requested hostname.

Only a not-found is translated to "no such endpoint"; a genuine store failure
surfaces, so a database outage cannot be mistaken for a miss.
2026-08-03 23:36:10 +02:00
Brad Ison
29ad3fad43 feat(agentnetwork): allocate subdomains transactionally, retrying on conflict
Allocation read a per-cluster set of taken labels, picked one, and wrote it
later. That had three defects: the set was per-cluster, which is wrong once
labels must be unique across a shared zone; the read and the write were not
atomic; and on pool exhaustion it appended the first four characters of the
account ID with no retry and no uniqueness check -- and those four characters
are constant for accounts created within roughly the same hour, so two such
accounts could be handed the same label.

Allocation now picks a label and inserts it inside a transaction, retrying
with a fresh label when the database rejects a duplicate, and failing loudly
when the attempt budget is exhausted. A fresh transaction per attempt is
required rather than incidental: on PostgreSQL a failed statement poisons the
enclosing transaction, so a single transaction wrapping the loop would fail
every attempt after the first.

Because the settings primary key is the account ID, a concurrent bootstrap
for the same account fails on the primary key rather than the subdomain
index. That is indistinguishable from a label collision by message, so the
loop re-reads by account before retrying and returns the winner's row -- the
same answer the sequential path gives.
2026-08-03 23:36:08 +02:00
Brad Ison
6203528f3a feat(agentnetwork): thread the zone config through to the manager
Adds AgentNetwork.Zone to the management config and passes it to the
agent-network manager, alongside the existing plumbing in the combined
binary. Nothing reads it yet -- the allocator that stamps it onto new rows
comes next -- so this commit is inert on its own.
2026-08-03 23:36:06 +02:00
Brad Ison
fabfacee55 feat(store): globally unique subdomains and an insert that surfaces conflicts
Once an endpoint hangs off a shared zone rather than a per-cluster address,
subdomain labels must be unique across the whole zone rather than within one
cluster. Uniqueness was previously advisory -- a pre-read "taken" set with no
database constraint -- so this adds a unique index on the column and makes
the database the arbiter.

CreateAgentNetworkSettings is a plain INSERT that returns the driver error
unwrapped, both of which the allocator depends on: SaveAgentNetworkSettings
is an upsert (which cannot conflict) and wraps failures in a generic internal
error, discarding the message that unique-violation detection needs.

Note for operators: the index is created by a migration that fails, and
therefore blocks startup, on a deployment that already holds two rows with
the same subdomain on different clusters -- which was legal under the old
per-cluster scheme. Audit for duplicates before upgrading.
2026-08-03 23:36:04 +02:00
Brad Ison
df3619e1e5 feat(agentnetwork): add a placement-free Zone to settings
A tenant's endpoint is <subdomain>.<cluster>, where the cluster half is the
address of the proxy serving them. That couples the hostname to placement:
the tenant cannot be served by a different proxy without their address
changing.

Zone is a parent DNS zone captured onto the settings row when the row is
created, making the endpoint <subdomain>.<zone> instead. It is persisted per
row rather than read from config at call time for two reasons: Endpoint()
must keep its no-argument signature, because a synthesizer is registered
against a pinned signature at init() time; and persisting makes a tenant's
address immutable, so editing server config never silently moves an existing
tenant.

Zone is empty for every existing row and for any deployment that configures
none, in which case Endpoint() falls back to the previous behaviour exactly.
2026-08-03 23:36:02 +02:00
Brad Ison
b2d72534c5 feat(agentnetwork): adjective-noun subdomain label generation
Adds PickTuple, which draws an adjective and a noun to form a single DNS
label such as "brave-otter". The existing single-word generator kept a
per-cluster "taken" set and guessed uniqueness up front; a later commit
replaces that with a database constraint and a retry, so PickTuple
deliberately takes no taken set and has no fallback suffix.

The adjectives live in their own pool rather than reusing the noun list,
which is almost entirely nouns -- drawing twice from it produces
"millet-hammock", which reads as noise rather than a name. Tests assert the
curation contract the pool depends on: duplicate-free, DNS-safe, and
disjoint from the nouns.
2026-08-03 23:36:00 +02:00
Viktor Liu
e90be36cd5 [client] Don't ask for an SSO login when the login never reached management (#6983) 2026-08-03 16:28:51 +02:00
Riccardo Manfrin
d29bc23bb7 [client] launch macOS GUI as the logged-in user after install/update (#6962)
## Describe your changes

In unattended installs/updates there may be no logged-in user, so
there's no context to start the GUI (nor anyone to see it).

The bug is the GUI being started in the wrong user context / inheriting
the wrong `$HOME` (OS mechanics aside).

Today the GUI is started by a per-user LaunchAgent, i.e. on behalf of
the user who logs in — no login ⇒ no GUI.

The patch aligns to this: it launches the GUI on behalf of the logged-in
console user if one exists, otherwise it delegates the launch to the
per-user LaunchAgent at next login.

Additionally it logs when default UI settings are applied.

Note (small caveat): the LaunchAgent auto-starts the GUI at login only
once it's been registered — which happens on the first GUI launch in the
user's context. On an MDM/unattended fresh install done with no user
logged in (where the user has never run the GUI before), they may need
to start it manually once; it self-registers from then on.

## Issue ticket number and link

No public issue — reported internally (community report on Slack: macOS
advanced-view + onboarding reset on every update, esp. via MDM/Munki).
Buggy line on main:
dd2bdc0de3/release_files/darwin_pkg/postinstall (L33)

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

> 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 macOS installer / GUI-launch behavior. No public API, CLI, or
configuration change: the fix only changes the user context the desktop
GUI is launched in after a pkg install/update.

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

N/A

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6962"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787923973&installation_model_id=427504&pr_number=6962&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6962&signature=5226bbc7986fc60eb8b4ab77e98ac17a6534864763fc6407048bcbbb1550394b"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

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

- **Bug Fixes**
- Improved macOS installation and updater UI launching to occur only
when an active, valid GUI console session is detected.
- Prevented UI launches during unattended/system, root, or
login-window-related installs.
- Ensured the app is launched in the correct console-user context, and
skips cleanly when username/UID resolution fails.

- **Improvements**
- Added clearer informational logging when the UI preferences file is
not found and default preferences are used.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 16:28:10 +02:00
Viktor Liu
ee1389d736 [client] Keep the UI running when the notification service fails to start (#6959) 2026-08-03 16:23:10 +02:00
Zoltan Papp
6f42636514 [client] Android - Serialize Android tunnel reconfiguration callbacks (#6990)
## Describe your changes

The Android route notifier and the DNS search-domain notifier both
delivered OnNetworkChanged from a fire-and-forget goroutine per update.
Two updates in quick succession could reach the Java side reordered: the
TUN rebuild handler applies them in arrival order and compares against
the last applied parameters, so a stale route set delivered last won as
the final TUN state. This is the same reordering hazard fixed for iOS in
#6454.

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

Also stop hasRouteDiff from sorting the notifier's shared route slices
in place; compare sorted copies instead.

## Issue ticket number and link

## 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)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> 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/__
2026-08-03 15:03:15 +02:00
Zoltan Papp
28197e6504 [client] Android - Create the Android fake IP manager lazily on DNS flag enable (#6989)
The fake IP manager was only created at route manager construction, from the DNS feature flag fetched by the initial GetNetworkMap call. When the flag flipped to true mid-session, UpdateRoutes set useNewDNSRoute but never created the manager, so domain routes added after the flip got a DNS interceptor with a nil fake IP manager.

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

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

This is groundwork for removing the initial GetNetworkMap fetch, after which every startup goes through the flag-off-to-on transition.
2026-08-03 14:30:41 +02:00
Zoltan Papp
2f721ec0d5 [client] Keep the account email backing the SSO login hint correct (#6986)
## Describe your changes

Three fixes to how the desktop GUI keeps the account email that backs
the SSO `login_hint`. Each is independent and reviewable on its own.

**1. Store the email after a GUI SSO login**

The daemon returns the authenticated user's email from `WaitSSOLogin`
but cannot persist it: it runs as root while the per-profile state file
is user-owned. The CLI's `handleSSOLogin` writes it after its own
`WaitSSOLogin`; the GUI path read the value and dropped it.

The profile was therefore left with no email, so `Profiles.List` showed
no account for it, and later logins and session extends went out with no
`login_hint` — leaving the IdP to pick an account instead of reusing the
one the profile belongs to. Mirror the CLI and store it, next to the
`Logout` path that already clears the same file for the same reason.

**2. File the email against the profile the login ran for**

`SetActiveProfileState` resolves the target itself, so it writes to
whichever profile is active when it is called. A GUI SSO login spans
seconds of user interaction in the browser, and the tray stays clickable
throughout: switching profiles in that window left the email filed under
the profile that happened to be active when the flow returned. The wrong
profile then advertised an account it does not own, and offered it as
the `login_hint` next time.

Adds `SetProfileState(id, state)`, the write-side counterpart of the
existing `GetProfileState(id)`, and keeps `SetActiveProfileState` as a
wrapper for callers with no particular profile in mind. `Login` now
reports the profile it resolved so the frontend can hand it back with
the SSO wait, which closes the window.

**3. Delete the email when a profile is removed**

Removing a profile left its state file behind: the daemon deletes what
it owns, but the email file is user-owned and out of reach for a root
daemon — the same split that already puts the `Logout` cleanup on the UI
side.

Beyond the stray file, legacy profiles are keyed by name rather than by
a generated ID, so recreating a profile under a removed one's name
inherited its email — shown as the account in the profile list and sent
as the `login_hint` on the next login.

## Issue ticket number and link

## 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)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> 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

- **New Features**
- SSO login details can now be saved to the specific profile selected
during sign-in.
  - Profile state can be managed independently for different profiles.

- **Bug Fixes**
  - Removing a profile now also cleans up its associated saved state.
- Cleanup issues no longer prevent successful profile removal and are
handled gracefully.
  - Existing active-profile behavior remains unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 14:21:40 +02:00
Pascal Fischer
f9b412228e [management] fix handling of empty network map during decode and encode (#6987) 2026-08-03 13:20:53 +02:00
Maycon Santos
2bfd9fcffe [management] Resolve agent network permissions per submodule (#7030)
## Describe your changes

Agent Network gates providers, policies, guardrails, budgets, usage,
access logs, and settings behind the single `agent_network` permission
module, so access is all-or-nothing: a future delegated role cannot be
scoped to a subset of the area (for example usage-only visibility).

This introduces dotted submodules (`agent_network.providers`,
`.policies`, `.guardrails`, `.budgets`, `.usage`, `.logs`, `.settings`)
and resolves grants with a cascade: exact module first, then its parent,
then the role's `AutoAllowNew` default. The agent network manager now
validates each operation against its matching submodule. `usage`
(aggregated counters, overview) is deliberately separate from `logs`
(request-level entries, which can contain captured prompts).

No role definitions change. No built-in role carries an explicit
`agent_network` entry, so every role resolves the submodules exactly as
it resolved the parent module before — pinned by a test that compares
each built-in role's answer on every submodule against its answer on
`agent_network`. Role additions that use these submodules come
separately.
2026-08-03 12:45:17 +02:00
Brad Ison
7639655883 [management] Generic gRPC extension seam for external modules (#6894)
## Describe your changes

This adds an extension point to the management server for registering
additional gRPC services. We already have a generic integrations system
and dependency injection for server components. This closes the gap on
being able to also extend the gRPC API cleanly.

## Issue ticket number and link

N/A

## Stack

<!-- branch-stack -->

### Checklist
- [ ] Is it a bug fix
- [ ] Is a typo/documentation fix
- [x] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

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

No docs needed. This is strictly a small internal plumbing enhancement /
refactor.

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6894"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787582002&installation_model_id=427504&pr_number=6894&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6894&signature=3288061677db243031830964fec8f0f34c82f7fc63a39298cd0b4e3490551060"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

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

* **New Features**
* Added a gRPC extension mechanism to contribute additional services and
automatically chain extra unary and stream interceptors.
  * Extension shutdown hooks now run as part of server stop.
* Added exported proxy token generation via `GenerateProxyToken()` for
external integrations.
* **Tests**
* Added coverage for extension interceptor/service wiring, extension
shutdown execution, and proxy token generation validation (including
hash consistency and prefix).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 12:26:38 +02:00
Zoltan Papp
6044663788 [client] Declare GTK4/WebKitGTK runtime deps for the Linux UI packages (#6893)
The 0.75.0 UI is built against GTK 4.14 (Ubuntu 24.04 runner) and Wails
v3 calls gdk_monitor_get_scale (GTK 4.14+) unconditionally, but the
deb/rpm packages only depended on netbird. On distros shipping an older
GTK4 (Ubuntu 22.04, RHEL 9, openSUSE Leap 15.6) the package installed
fine and then died at startup with a symbol lookup error (#6890).

Declare the real runtime dependencies so package managers reject the
install up front instead:

- deb: libgtk-4-1 (>= 4.14) and libwebkitgtk-6.0-4
- rpm: rich (boolean) dependencies that accept both the Fedora/RHEL and
the SUSE package names, with the 4.14 floor: (gtk4 >= 4.14 or libgtk-4-1
>= 4.14) (webkitgtk6.0 or libwebkitgtk-6_0-4)

Rich deps are supported by dnf and zypper (RPM 4.13+); the pinned
goreleaser v2.16.0 -> nfpm v2.46.3 -> rpmpack v0.7.1 chain passes the
parenthesized form through verbatim. On RHEL/Alma/Rocky 10 the
webkitgtk6.0 package comes from EPEL, which becomes an install
prerequisite for the UI.

The wails3 packaging config (client/ui/build/linux/nfpm/nfpm.yaml, local
dev packaging only) is kept in sync.

## Describe your changes

## Issue ticket number and link

## 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)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> 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/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6893"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787575510&installation_model_id=427504&pr_number=6893&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6893&signature=93b4fc344fe4f4e6b781887386bfbae1a309007205c7b2ab061b58d89e29b9a8"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

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

* **Bug Fixes**
* Improved Linux package installation compatibility by requiring
supported GTK 4.14 and WebKit components.
* Updated Debian and RPM packages to recognize equivalent
platform-specific library names.
* Refined Linux package metadata to support installation across a wider
range of distributions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 10:21:14 +02:00
63 changed files with 2133 additions and 321 deletions

View File

@@ -93,7 +93,9 @@ nfpms:
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird
- netbird (>= 0.75.0)
- libgtk-4-1 (>= 4.14)
- libwebkitgtk-6.0-4
- maintainer: Netbird <dev@netbird.io>
description: Netbird client UI.
@@ -114,7 +116,9 @@ nfpms:
- src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png
dependencies:
- netbird
- netbird >= 0.75.0
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
rpm:
signature:

View File

@@ -113,11 +113,14 @@ func (c *ConnectClient) RunOnAndroid(
stateFilePath string,
cacheDir string,
) error {
notifier := tunnelnotifier.New(networkChangeListener, nil)
defer notifier.Close()
// in case of non Android os these variables will be nil
mobileDependency := MobileDependency{
TunAdapter: tunAdapter,
IFaceDiscover: iFaceDiscover,
NetworkChangeListener: networkChangeListener,
NetworkChangeListener: notifier,
HostDNSAddresses: dnsAddresses,
DnsReadyListener: dnsReadyListener,
StateFilePath: stateFilePath,

View File

@@ -51,7 +51,5 @@ func (n *notifier) notify() {
return
}
go func(l listener.NetworkChangeListener) {
l.OnNetworkChanged("")
}(n.listener)
n.listener.OnNetworkChanged("")
}

View File

@@ -45,12 +45,35 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
return &state, nil
}
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
// SetProfileState writes the state file of the profile identified by id. Prefer
// it over SetActiveProfileState whenever the caller knows which profile the data
// belongs to: an SSO login spans seconds of user interaction, and the active
// profile can change during it, which would file the account email under
// whichever profile happened to be active when the flow returned.
func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
configDir, err := getConfigDir()
if err != nil {
return fmt.Errorf("get config directory: %w", err)
}
if id == "" {
return fmt.Errorf("empty profile ID")
}
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
return fmt.Errorf("invalid profile ID: %q", id)
}
stateFile := filepath.Join(configDir, id.String()+".state.json")
if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil {
return fmt.Errorf("write profile state: %w", err)
}
return nil
}
// SetActiveProfileState writes the state file of whichever profile is active at
// call time. Use SetProfileState when the target profile is known.
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
activeProf, err := pm.GetActiveProfile()
if err != nil {
if errors.Is(err, ErrNoActiveProfile) {
@@ -59,18 +82,7 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return fmt.Errorf("get active profile: %w", err)
}
id := activeProf.ID
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
return fmt.Errorf("invalid active profile ID: %q", id)
}
stateFile := filepath.Join(configDir, id.String()+".state.json")
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
if err != nil {
return fmt.Errorf("write profile state: %w", err)
}
return nil
return pm.SetProfileState(activeProf.ID, state)
}
// RemoveProfileState deletes the per-profile state file (which holds the

View File

@@ -479,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger
// internalDnatFw checks if the firewall supports internal DNAT
func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) {
if d.firewall == nil || runtime.GOOS != "android" {
if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" {
return nil, false
}
fw, ok := d.firewall.(internalDNATer)

View File

@@ -165,31 +165,36 @@ func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
routesForComparison := slices.Clone(cr)
if config.DNSFeatureFlag {
m.fakeIPManager = fakeip.NewManager()
v4ID := uuid.NewString()
fakeIPRoute := &route.Route{
ID: route.ID(v4ID),
Network: m.fakeIPManager.GetFakeIPBlock(),
NetID: route.NetID(v4ID),
Peer: m.pubKey,
NetworkType: route.IPv4Network,
}
v6ID := uuid.NewString()
fakeIPv6Route := &route.Route{
ID: route.ID(v6ID),
Network: m.fakeIPManager.GetFakeIPv6Block(),
NetID: route.NetID(v6ID),
Peer: m.pubKey,
NetworkType: route.IPv6Network,
}
cr = append(cr, fakeIPRoute, fakeIPv6Route)
m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route})
cr = append(cr, m.enableFakeIPRoutes()...)
}
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
}
func (m *DefaultManager) enableFakeIPRoutes() []*route.Route {
m.fakeIPManager = fakeip.NewManager()
v4ID := uuid.NewString()
fakeIPRoute := &route.Route{
ID: route.ID(v4ID),
Network: m.fakeIPManager.GetFakeIPBlock(),
NetID: route.NetID(v4ID),
Peer: m.pubKey,
NetworkType: route.IPv4Network,
}
v6ID := uuid.NewString()
fakeIPv6Route := &route.Route{
ID: route.ID(v6ID),
Network: m.fakeIPManager.GetFakeIPv6Block(),
NetID: route.NetID(v6ID),
Peer: m.pubKey,
NetworkType: route.IPv6Network,
}
fakeRoutes := []*route.Route{fakeIPRoute, fakeIPv6Route}
m.notifier.SetFakeIPRoutes(fakeRoutes)
return fakeRoutes
}
func (m *DefaultManager) setupRefCounters(useNoop bool) {
var once sync.Once
var wgIface *net.Interface
@@ -464,6 +469,9 @@ func (m *DefaultManager) UpdateRoutes(
var merr *multierror.Error
if !m.disableClientRoutes {
if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil {
m.enableFakeIPRoutes()
}
// Update route selector based on management server's isSelected status
m.updateRouteSelectorFromManagement(clientRoutes)

View File

@@ -41,6 +41,7 @@ func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesFo
// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild.
func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) {
n.fakeIPRoutes = routes
n.notify()
}
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
@@ -78,9 +79,7 @@ func (n *Notifier) notify() {
routeStrings := n.routesToStrings(allRoutes)
sort.Strings(routeStrings)
go func(l listener.NetworkChangeListener) {
l.OnNetworkChanged(strings.Join(routeStrings, ","))
}(n.listener)
n.listener.OnNetworkChanged(strings.Join(routeStrings, ","))
}
func filterStatic(routes []*route.Route) []*route.Route {
@@ -102,16 +101,11 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
}
func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
slices.SortFunc(a, func(x, y *route.Route) int {
return strings.Compare(x.NetString(), y.NetString())
})
slices.SortFunc(b, func(x, y *route.Route) int {
return strings.Compare(x.NetString(), y.NetString())
})
return !slices.EqualFunc(a, b, func(x, y *route.Route) bool {
return x.NetString() == y.NetString()
})
as := n.routesToStrings(a)
bs := n.routesToStrings(b)
sort.Strings(as)
sort.Strings(bs)
return !slices.Equal(as, bs)
}
func (n *Notifier) GetInitialRouteRanges() []string {

View File

@@ -98,47 +98,44 @@ func (u *Installer) startDaemon(daemonFolder string) error {
func (u *Installer) startUIAsUser() error {
log.Infof("starting netbird-ui: %s", uiBinary)
// Get the current console user
cmd := exec.Command("stat", "-f", "%Su", "/dev/console")
output, err := cmd.Output()
username, err := consoleUser()
if err != nil {
return fmt.Errorf("failed to get console user: %w", err)
return err
}
username := strings.TrimSpace(string(output))
if username == "" || username == "root" {
return fmt.Errorf("no active user session found")
}
log.Infof("starting UI for user: %s", username)
// Get user's UID
userInfo, err := user.Lookup(username)
if err != nil {
return fmt.Errorf("failed to lookup user %s: %w", username, err)
return fmt.Errorf("lookup user %s: %w", username, err)
}
// Start the UI process as the console user using launchctl
// This ensures the app runs in the user's context with proper GUI access
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary)
log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid)
launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary)
log.Infof("launchCmd: %s", launchCmd.String())
// Set the user's home directory for proper macOS app behavior
launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir)
log.Infof("set HOME environment variable: %s", userInfo.HomeDir)
if err := launchCmd.Start(); err != nil {
return fmt.Errorf("failed to start UI process: %w", err)
}
// Release the process so it can run independently
if err := launchCmd.Process.Release(); err != nil {
log.Warnf("failed to release UI process: %v", err)
if err := launchCmd.Run(); err != nil {
return fmt.Errorf("run UI launch: %w", err)
}
log.Infof("netbird-ui started successfully for user %s", username)
return nil
}
func consoleUser() (string, error) {
output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output()
if err != nil {
return "", fmt.Errorf("get console user: %w", err)
}
username := strings.TrimSpace(string(output))
switch username {
case "", "root", "loginwindow", "_mbsetupuser":
return "", fmt.Errorf("no active GUI user session, console user: %q", username)
}
return username, nil
}
func (u *Installer) installPkgFile(ctx context.Context, path string) error {
log.Infof("installing pkg file: %s", path)

View File

@@ -0,0 +1,89 @@
package server
import (
"context"
"encoding/json"
"errors"
"os"
"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"
)
// A login that never reached Management is not a decision about the peer's
// credentials, so it must come back as a retryable error rather than an SSO
// prompt: the user cannot finish a browser login while Management is down, and
// the CLI's own backoff resolves the outage on its own once the daemon reports
// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
// because Management happened to be restarting when the daemon dialed it.
func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
unreachable := errors.New("create connection: dial context: context deadline exceeded")
attempts := 0
s.loginAttemptFn = func(context.Context, string, string) (internal.StatusType, error) {
attempts++
return internal.StatusLoginFailed, unreachable
}
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
require.Nil(t, resp, "a failed login must not answer with a login response")
require.Equal(t, 1, attempts)
require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
status, err := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, err)
require.Equal(t, internal.StatusLoginFailed, status,
"a peer that could not reach Management is not waiting on a login")
}
// The counterpart: Management refusing the peer's credentials is a decision, and
// the SSO flow still has to start for it. The profile carries an unusable
// private key so the flow setup fails immediately instead of dialing, which is
// enough to show the branch was entered — the refusal itself is never what comes
// back out.
func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
s, _, _, username, cfgPath := setupServerWithProfile(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
}
_, 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)
require.Equal(t, internal.StatusLoginFailed, status,
"the SSO flow setup was never reached with the broken key")
}
// 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) {
t.Helper()
raw, err := os.ReadFile(cfgPath)
require.NoError(t, err)
var cfg map[string]any
require.NoError(t, json.Unmarshal(raw, &cfg))
cfg["PrivateKey"] = "not-a-key"
patched, err := json.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
}

View File

@@ -135,6 +135,11 @@ type Server struct {
updateManager *updater.Manager
jwtCache *jwtCache
// loginAttemptFn stands in for the Management login round trip. Tests set
// 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)
}
type oauthAuthFlow struct {
@@ -370,7 +375,19 @@ func (s *Server) connectionGoroutineRunning() bool {
}
}
// loginAttempt attempts to login using the provided information. it returns a status in case something fails
// attemptLogin runs a login round trip against Management, or the stand-in a
// test installed in place of it.
func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
if s.loginAttemptFn != nil {
return s.loginAttemptFn(ctx, setupKey, jwtToken)
}
return s.loginAttempt(ctx, setupKey, jwtToken)
}
// 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
// authentication decision apart from a login that never got made.
func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) {
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
if err != nil {
@@ -623,11 +640,23 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
s.config = config
s.mutex.Unlock()
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
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
// 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)
return nil, err
}
if msg.SetupKey == "" {
hint := ""
if msg.Hint != nil {
@@ -684,7 +713,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
// which returns NeedsLogin and parks on the browser leg.
state.Set(internal.StatusConnecting)
if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil {
if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil {
state.Set(loginStatus)
return nil, err
}
@@ -839,7 +868,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
s.oauthAuthFlow.expiresAt = time.Now()
s.mutex.Unlock()
if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil {
state.Set(loginStatus)
return nil, err
}

View File

@@ -26,17 +26,17 @@ contents:
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:
- libgtk-4-1
- libgtk-4-1 (>= 4.14)
- libwebkitgtk-6.0-4
- xdg-utils
# Distribution-specific overrides for different package formats
overrides:
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE
rpm:
depends:
- gtk4
- webkitgtk6.0
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
- (webkitgtk6.0 or libwebkitgtk-6_0-4)
- xdg-utils
# Arch Linux packages

View File

@@ -43,7 +43,12 @@ function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<v
}
async function runSsoLogin(
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
result: {
verificationUri: string;
verificationUriComplete: string;
userCode: string;
profileId: string;
},
state: SsoState,
signal?: AbortSignal,
): Promise<void> {
@@ -56,7 +61,7 @@ async function runSsoLogin(
// suspended, so a frontend-driven Up (a promise continuation) would not
// fire until the user woke the window (e.g. hovering the tray icon).
const waitPromise = Connection.WaitSSOLoginAndUp(
{ userCode: result.userCode, hostname: "" },
{ userCode: result.userCode, hostname: "", profileId: result.profileId },
{ profileName: "", username: "" },
);

View File

@@ -14,7 +14,6 @@ import (
"github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/i18n"
@@ -63,7 +62,7 @@ type registeredServices struct {
profiles *services.Profiles
update *services.Update
daemonFeed *services.DaemonFeed
notifier *notifications.NotificationService
notifier *Notifier
compat *services.Compat
profileSwitcher *services.ProfileSwitcher
bundle *i18n.Bundle
@@ -102,7 +101,7 @@ func main() {
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
notifier := notifications.New()
notifier := newNotifier()
compat := services.NewCompat(conn)
// macOS shows no toast until permission is requested. Run it after
// ApplicationStarted so the notifier's Startup has initialised the
@@ -210,7 +209,7 @@ func main() {
// requestNotificationAuthorization prompts for macOS notification permission.
// The request blocks until the user responds (up to 3 minutes), so callers run
// it in a goroutine. No-op on Linux/Windows.
func requestNotificationAuthorization(notifier *notifications.NotificationService) {
func requestNotificationAuthorization(notifier *Notifier) {
authorized, err := notifier.CheckNotificationAuthorization()
if err != nil {
logrus.Debugf("check notification authorization: %v", err)

101
client/ui/notifier.go Normal file
View File

@@ -0,0 +1,101 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"errors"
"sync/atomic"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
var errNotificationsUnavailable = errors.New("notifications unavailable")
// Notifier wraps the Wails notification service so an unavailable backend
// disables notifications instead of aborting the app. Startup fails for
// environment reasons (a bare unbundled binary on macOS has no bundle
// identifier, a headless Linux session has no D-Bus session bus), and Wails
// treats a service startup error as fatal. After a failed startup every call
// is a no-op: on macOS, touching UNUserNotificationCenter without a bundle
// identifier raises an Objective-C exception that recover() cannot catch.
type Notifier struct {
inner *notifications.NotificationService
available atomic.Bool
}
func newNotifier() *Notifier {
return &Notifier{inner: notifications.New()}
}
// ServiceName implements the Wails service-name hook for startup logs.
func (n *Notifier) ServiceName() string {
return n.inner.ServiceName()
}
// ServiceStartup starts the platform notifier, downgrading failure to a
// warning so the app keeps running without notifications.
func (n *Notifier) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
if err := n.inner.ServiceStartup(ctx, options); err != nil {
log.Warnf("notifications disabled: %v", err)
return nil
}
n.available.Store(true)
return nil
}
func (n *Notifier) ServiceShutdown() error {
if !n.available.Load() {
return nil
}
return n.inner.ServiceShutdown()
}
func (n *Notifier) CheckNotificationAuthorization() (bool, error) {
if !n.available.Load() {
return false, errNotificationsUnavailable
}
return n.inner.CheckNotificationAuthorization()
}
func (n *Notifier) RequestNotificationAuthorization() (bool, error) {
if !n.available.Load() {
return false, errNotificationsUnavailable
}
return n.inner.RequestNotificationAuthorization()
}
// SendNotification delivers a notification, silently dropping it when the
// backend never started (notifications are best-effort everywhere).
func (n *Notifier) SendNotification(options notifications.NotificationOptions) error {
if !n.available.Load() {
log.Debugf("notifications disabled, dropping %q", options.ID)
return nil
}
return n.inner.SendNotification(options)
}
func (n *Notifier) SendNotificationWithActions(options notifications.NotificationOptions) error {
if !n.available.Load() {
log.Debugf("notifications disabled, dropping %q", options.ID)
return nil
}
return n.inner.SendNotificationWithActions(options)
}
func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error {
if !n.available.Load() {
return nil
}
return n.inner.RegisterNotificationCategory(category)
}
// OnNotificationResponse registers the response callback. Pure Go state, so
// it is safe (and simply inert) when the backend never started.
//
//wails:ignore
func (n *Notifier) OnNotificationResponse(callback func(result notifications.NotificationResult)) {
n.inner.OnNotificationResponse(callback)
}

View File

@@ -246,6 +246,7 @@ func (s *Store) ExistedAtLoad() bool {
func (s *Store) load() error {
if _, err := os.Stat(s.path); err != nil {
if errors.Is(err, os.ErrNotExist) {
log.Infof("no ui preferences file at %s; using defaults", s.path)
return nil
}
return fmt.Errorf("stat preferences: %w", err)

View File

@@ -33,12 +33,21 @@ type LoginResult struct {
UserCode string `json:"userCode"`
VerificationURI string `json:"verificationUri"`
VerificationURIComplete string `json:"verificationUriComplete"`
// ProfileID is the ID of the profile this login ran against, or "" when the
// caller named the profile itself and no ID was resolved. Pass it back in
// WaitSSOParams so the account email lands on this profile even if the
// active one changes during SSO.
ProfileID string `json:"profileId"`
}
// WaitSSOParams are the inputs to waitSSOLogin.
type WaitSSOParams struct {
UserCode string `json:"userCode"`
Hostname string `json:"hostname"`
// ProfileID is the profile the login was started for, used to file the
// account email against it rather than against whichever profile is active
// when the flow returns. Optional: empty falls back to the active profile.
ProfileID string `json:"profileId"`
}
// UpParams selects the profile to bring up.
@@ -77,11 +86,16 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
// Fall back to the daemon's active profile and the current OS user.
profileName := p.ProfileName
username := p.Username
// Only set when the daemon told us the ID. A caller-supplied ProfileName is
// a handle — a display name or an ID prefix resolve too — and the state file
// is named after the ID, so passing a handle on would name the wrong file.
profileID := ""
if profileName == "" {
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
// Address the active profile by ID (the daemon resolves it as a
// handle); names can collide, the ID cannot.
profileName = active.GetId()
profileID = profileName
if username == "" {
username = active.GetUsername()
}
@@ -122,6 +136,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
UserCode: resp.GetUserCode(),
VerificationURI: resp.GetVerificationURI(),
VerificationURIComplete: resp.GetVerificationURIComplete(),
ProfileID: profileID,
}, nil
}
@@ -242,6 +257,31 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
return "", s.classifyDaemonError(err)
}
log.Infof("SSO login completed, daemon reported success")
// Persist the account email the same way the CLI does after its own
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
// root and the per-profile state file is user-owned (see Logout below).
// Without this the profile has no email, so Profiles.List shows no account
// and later logins and session extends go out without a login_hint —
// leaving the IdP to guess which account was meant.
if email := resp.GetEmail(); email != "" {
state := &profilemanager.ProfileState{Email: email}
pm := profilemanager.NewProfileManager()
// Against the profile the login was started for: SSO spans seconds of
// user interaction, and a profile switch in that window would otherwise
// file the email under the wrong profile.
if p.ProfileID != "" {
err = pm.SetProfileState(profilemanager.ID(p.ProfileID), state)
} else {
err = pm.SetActiveProfileState(state)
}
if err != nil {
// Non-fatal: the login itself succeeded.
log.Warnf("failed to store account email: %v", err)
}
}
return resp.GetEmail(), nil
}

View File

@@ -6,6 +6,8 @@ import (
"context"
"os/user"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
@@ -151,11 +153,31 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
if err != nil {
return err
}
_, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
return err
if err != nil {
return err
}
// The daemon deletes what it owns but runs as root, so it leaves the
// user-owned state file holding the account email behind (same split as
// Connection.Logout). Legacy profiles are keyed by name rather than by a
// generated ID, so a recreated profile of the same name would inherit the
// deleted one's email and offer it as the login_hint.
//
// Keyed on the ID the daemon resolved, not on the request handle: that may
// have been a display name or an ID prefix, which would name a different
// file (or none).
if id := resp.GetId(); id != "" {
if err := profilemanager.NewProfileManager().RemoveProfileState(id); err != nil {
// Non-fatal: the profile itself is gone.
log.Warnf("failed to remove profile state for %s: %v", id, err)
}
}
return nil
}
// Rename changes a profile's display name. The on-disk ID is unaffected, so

View File

@@ -44,7 +44,7 @@ type TrayServices struct {
Profiles *services.Profiles
Networks *services.Networks
DaemonFeed *services.DaemonFeed
Notifier *notifications.NotificationService
Notifier *Notifier
Update *services.Update
ProfileSwitcher *services.ProfileSwitcher
WindowManager *services.WindowManager

View File

@@ -44,7 +44,7 @@ func safeSendNotification(send sendFn, what string, opts notifications.Notificat
// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it
// is reachable but too old for this UI. A probe error means the daemon isn't
// reachable (not outdated), so it is left to the normal connection flow.
func notifyIfDaemonOutdated(compat *services.Compat, notifier *notifications.NotificationService, loc *Localizer) {
func notifyIfDaemonOutdated(compat *services.Compat, notifier *Notifier, loc *Localizer) {
ready, err := compat.DaemonReady(context.Background())
if err != nil {
log.Debugf("daemon compatibility probe: %v", err)

View File

@@ -21,7 +21,7 @@ type trayUpdater struct {
app *application.App
window *application.WebviewWindow
update *services.Update
notifier *notifications.NotificationService
notifier *Notifier
loc *Localizer
onIconChange func()
// onMenuChange drives a full tray relayout: the update row lives in the
@@ -36,7 +36,7 @@ type trayUpdater struct {
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,

View File

@@ -83,6 +83,7 @@ type ServerConfig struct {
// AgentNetworkConfig contains agent-network (LLM gateway) configuration.
type AgentNetworkConfig struct {
PricingDefaultsFile string `yaml:"pricingDefaultsFile"`
Zone string `yaml:"zone"`
}
// TLSConfig contains TLS/HTTPS settings
@@ -732,6 +733,7 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) {
PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions,
AgentNetwork: nbconfig.AgentNetwork{
PricingDefaultsFile: c.Server.AgentNetwork.PricingDefaultsFile,
Zone: c.Server.AgentNetwork.Zone,
},
}, nil
}

View File

@@ -147,3 +147,11 @@ server:
# # is re-read periodically (mtime poll). An explicitly configured path that
# # fails to load fails startup; runtime reload errors keep the previous table.
# pricingDefaultsFile: "pricing.yaml"
#
# # Parent DNS zone that Agent Network gateway endpoints are allocated
# # under, producing <subdomain>.<zone>. Empty (the default) preserves the
# # legacy behaviour of deriving the endpoint from the serving cluster, so
# # self-hosted deployments are unaffected. Captured onto each settings row
# # when that row is created; changing it later does not move existing
# # tenants.
# zone: "gateway.example.com"

View File

@@ -39,6 +39,9 @@
]
},
"DisableDefaultPolicy": $NETBIRD_MGMT_DISABLE_DEFAULT_POLICY,
"AgentNetwork": {
"Zone": "$NETBIRD_AGENT_NETWORK_ZONE"
},
"Datadir": "",
"DataStoreEncryptionKey": "$NETBIRD_DATASTORE_ENC_KEY",
"StoreConfig": {

View File

@@ -0,0 +1,296 @@
package agentnetwork
import (
"context"
"errors"
"fmt"
"math/rand"
"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/labelgen"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/store"
nbtypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
// TestIsUniqueConstraintError_RecognisesAllThreeDialects — the allocator's
// retry loop hinges on this. A missed dialect turns a retryable collision into
// a hard provider-create failure.
func TestIsUniqueConstraintError_RecognisesAllThreeDialects(t *testing.T) {
for name, err := range map[string]error{
"postgres": errors.New(`ERROR: duplicate key value violates unique constraint (SQLSTATE 23505)`),
"mysql": errors.New(`Error 1062 (23000): Duplicate entry 'brave-otter'`),
"sqlite": errors.New(`UNIQUE constraint failed: agent_network_settings.subdomain`),
} {
assert.True(t, isUniqueConstraintError(err), "%s violation must be recognised", name)
}
assert.False(t, isUniqueConstraintError(errors.New("connection refused")),
"unrelated errors must not be treated as retryable collisions")
}
// newAllocatorTestStore wires a real sqlite store, mirroring the pattern in
// provider_bootstrap_test.go's bootstrapFixture. The allocator tests exercise
// bootstrapSettingsIfNeeded directly against a managerImpl built in-package,
// so no permissions manager or account manager is needed.
func newAllocatorTestStore(t *testing.T) store.Store {
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)
return st
}
// TestBootstrapSettings_StampsZoneAndTupleLabel — new rows must carry the
// configured zone and a tuple label, which together give the tenant a
// placement-independent address.
func TestBootstrapSettings_StampsZoneAndTupleLabel(t *testing.T) {
ctx := context.Background()
st := newAllocatorTestStore(t)
m := &managerImpl{
store: st,
zone: "gateway.example",
labelRng: rand.New(rand.NewSource(1)),
}
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.NoError(t, err, "bootstrap must succeed")
require.NotNil(t, settings)
assert.Equal(t, "gateway.example", settings.Zone, "new row must carry the configured zone")
assert.Equal(t, "cluster1.example.com", settings.Cluster)
assert.Contains(t, settings.Subdomain, "-", "subdomain must be an adjective-noun tuple label")
assert.Equal(t, "account1", settings.AccountID)
assert.Equal(t, settings.Subdomain+".gateway.example", settings.Endpoint(),
"endpoint must be placement-independent, hanging off the zone rather than the cluster")
persisted, err := st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
require.NoError(t, err)
assert.Equal(t, settings.Subdomain, persisted.Subdomain, "returned settings must match the persisted row")
assert.Equal(t, "gateway.example", persisted.Zone)
}
// TestBootstrapSettings_RetriesOnCollision forces a duplicate by pre-inserting
// a row whose subdomain matches the next label the seeded rng will draw, then
// asserts allocation still succeeds with a different label and that no error
// escapes.
func TestBootstrapSettings_RetriesOnCollision(t *testing.T) {
ctx := context.Background()
st := newAllocatorTestStore(t)
const seed = 7
// Precompute the label a freshly seeded rng will draw first, without
// disturbing the rng the manager will actually use.
predictor := rand.New(rand.NewSource(seed))
firstDraw := labelgen.PickTuple(predictor)
require.NotEmpty(t, firstDraw, "test precondition: label pools must be non-empty")
// Pre-insert a colliding row on a different account so the allocator's
// first attempt hits the unique index and must retry.
require.NoError(t, st.CreateAgentNetworkSettings(ctx, &types.Settings{
AccountID: "other-account",
Cluster: "cluster1.example.com",
Subdomain: firstDraw,
}), "seeding the colliding row must succeed")
m := &managerImpl{
store: st,
labelRng: rand.New(rand.NewSource(seed)),
}
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.NoError(t, err, "allocation must succeed after retrying past the collision")
require.NotNil(t, settings)
assert.NotEqual(t, firstDraw, settings.Subdomain,
"the retried allocation must not reuse the already-taken label")
}
// TestBootstrapSettings_IsIdempotent — calling twice for one account returns
// the existing row unchanged (the early-return path), and does NOT
// re-allocate.
func TestBootstrapSettings_IsIdempotent(t *testing.T) {
ctx := context.Background()
st := newAllocatorTestStore(t)
m := &managerImpl{
store: st,
labelRng: rand.New(rand.NewSource(3)),
}
first, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.NoError(t, err)
require.NotNil(t, first)
second, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster2.example.com")
require.NoError(t, err, "second call must not error")
require.NotNil(t, second)
assert.Equal(t, first.Subdomain, second.Subdomain, "second call must return the existing subdomain unchanged")
assert.Equal(t, first.Cluster, second.Cluster, "second call must not repin the cluster to the new hint")
all, err := st.GetAllAgentNetworkSettings(ctx, store.LockingStrengthNone)
require.NoError(t, err)
var forAccount int
for _, s := range all {
if s.AccountID == "account1" {
forAccount++
}
}
assert.Equal(t, 1, forAccount, "exactly one row must exist for the account; no re-allocation")
}
// TestBootstrapSettings_FailsAfterExhaustingAttempts — the retry loop's
// failure mode. maxSubdomainAllocationAttempts consecutive collisions must
// surface an error rather than inserting a duplicate, silently succeeding, or
// looping forever.
//
// Seed 11 was checked to produce maxSubdomainAllocationAttempts distinct
// labels from labelgen.PickTuple; a seed that repeated a label would leave
// fewer than maxAttempts rows pre-inserted and the allocator would succeed on
// the repeat instead of exhausting.
func TestBootstrapSettings_FailsAfterExhaustingAttempts(t *testing.T) {
ctx := context.Background()
st := newAllocatorTestStore(t)
const seed = 11
predictor := rand.New(rand.NewSource(seed))
seen := make(map[string]struct{}, maxSubdomainAllocationAttempts)
for i := 0; i < maxSubdomainAllocationAttempts; i++ {
label := labelgen.PickTuple(predictor)
_, dup := seen[label]
require.False(t, dup, "test precondition: seed %d must draw %d distinct labels, got a repeat %q at draw %d", seed, maxSubdomainAllocationAttempts, label, i)
seen[label] = struct{}{}
require.NoError(t, st.CreateAgentNetworkSettings(ctx, &types.Settings{
AccountID: fmt.Sprintf("squatter-%d", i),
Cluster: "cluster1.example.com",
Subdomain: label,
}), "seeding colliding row %d must succeed", i)
}
m := &managerImpl{
store: st,
labelRng: rand.New(rand.NewSource(seed)),
}
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.Error(t, err, "exhausting every attempt to a collision must not silently succeed")
assert.Nil(t, settings, "no settings row may be returned on failure")
assert.Contains(t, err.Error(), "attempts exhausted")
_, err = st.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
assert.Error(t, err, "no settings row must be persisted for the account when allocation fails")
}
// TestBootstrapSettings_ConcurrentBootstrapReturnsWinnersRow covers the
// same-account race: Settings' primary key is AccountID, and the
// existence pre-check in bootstrapSettingsIfNeeded runs outside the
// transaction, so two concurrent first-provider creates for the same
// account can both observe NotFound and both proceed to allocate. The
// loser's INSERT then fails on the primary key rather than the subdomain
// unique index — a string isUniqueConstraintError still recognises — and
// must not be treated as a label collision to retry past; it must
// re-read and return the winner's row.
//
// This is scripted against a gomock store rather than driven by real
// goroutines against the sqlite test store: NewTestStoreFromSQL caps the
// pool at a single open connection (see its startup log,
// "max open db connections to 1"), which serialises statement execution
// enough that reliably forcing the exact interleaving this test needs —
// both pre-checks observing NotFound before either INSERT lands — would
// depend on goroutine scheduling rather than the store, making a
// real-goroutine version flaky rather than deterministic. Scripting the
// exact sequence (pre-check miss, PK-shaped insert failure, re-read hit)
// through a MockStore exercises the same re-read branch precisely and
// deterministically.
func TestBootstrapSettings_ConcurrentBootstrapReturnsWinnersRow(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
winner := &types.Settings{
AccountID: "account1",
Cluster: "cluster1.example.com",
Subdomain: "brave-otter",
}
gomock.InOrder(
// The pre-check: no row yet, so this bootstrap proceeds to allocate.
mockStore.EXPECT().
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
Return(nil, status.Errorf(status.NotFound, "agent network settings not found")),
// The insert loses the race. The message shape is the sqlite wording
// for a primary-key violation on account_id (not the subdomain
// index); this test locks down that the retry path recognizes that
// shape as a race loss and re-reads the winner's row, rather than
// misclassifying it as a subdomain conflict.
mockStore.EXPECT().
ExecuteInTransaction(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, f func(store.Store) error) error {
return f(mockStore)
}),
// The re-read after the PK conflict finds the concurrent winner's row.
mockStore.EXPECT().
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
Return(winner, nil),
)
mockStore.EXPECT().
CreateAgentNetworkSettings(gomock.Any(), gomock.Any()).
Return(errors.New("UNIQUE constraint failed: agent_network_settings.account_id"))
m := &managerImpl{
store: mockStore,
labelRng: rand.New(rand.NewSource(9)),
}
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.NoError(t, err, "losing the same-account race must not surface as an error")
require.NotNil(t, settings)
assert.Same(t, winner, settings, "the loser must return the concurrent winner's row, not retry past it")
}
// TestBootstrapSettings_NonRetryableErrorFailsImmediately guards the
// isUniqueConstraintError branch itself: a regression that dropped that check
// and retried on every ExecuteInTransaction error would leave every other test
// in this file green, because none of them feed the loop a non-collision
// failure. A generic store error must surface immediately, wrapped, and must
// not be retried — asserting ExecuteInTransaction was called exactly once is
// what proves the loop didn't retry.
func TestBootstrapSettings_NonRetryableErrorFailsImmediately(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
mockStore.EXPECT().
GetAgentNetworkSettings(gomock.Any(), store.LockingStrengthNone, "account1").
Return(nil, status.Errorf(status.NotFound, "agent network settings not found"))
mockStore.EXPECT().
ExecuteInTransaction(gomock.Any(), gomock.Any()).
Return(errors.New("connection refused")).
Times(1)
m := &managerImpl{
store: mockStore,
labelRng: rand.New(rand.NewSource(5)),
}
settings, err := m.bootstrapSettingsIfNeeded(ctx, "account1", "cluster1.example.com")
require.Error(t, err, "a non-collision store error must surface, not be swallowed")
assert.Nil(t, settings)
assert.Contains(t, err.Error(), "create agent network settings",
"the non-retryable error must be wrapped and returned, not retried past")
}

View File

@@ -0,0 +1,120 @@
package agentnetwork
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/store"
)
// TestSynthesizeServiceForDomain_ResolvesZoneBasedEndpoint — with a Zone the
// hostname's parent is the zone, not the cluster, so the old "strip the first
// label and match a cluster" prefilter found nothing and every zone-based
// tenant failed to resolve on the auth path.
func TestSynthesizeServiceForDomain_ResolvesZoneBasedEndpoint(t *testing.T) {
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
settings := newSynthTestSettings()
settings.Cluster = "eu.proxy.netbird.io"
settings.Zone = "gateway.netbird.ai"
settings.Subdomain = "brave-otter"
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
domain := "brave-otter.gateway.netbird.ai"
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
require.NoError(t, err)
require.NotNil(t, svc, "zone-based endpoint must resolve to the owning account's service")
assert.Equal(t, domain, svc.Domain)
}
// TestSynthesizeServiceForDomain_ResolvesLegacyClusterEndpoint — the
// non-breaking guarantee. A row with no Zone still resolves at
// <subdomain>.<cluster>, because the subdomain is the first label either way.
func TestSynthesizeServiceForDomain_ResolvesLegacyClusterEndpoint(t *testing.T) {
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
settings := newSynthTestSettings()
settings.Cluster = "eu.proxy.netbird.io"
settings.Zone = ""
settings.Subdomain = "swift-heron"
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
domain := "swift-heron.eu.proxy.netbird.io"
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
require.NoError(t, err)
require.NotNil(t, svc, "legacy cluster-based endpoint must still resolve")
assert.Equal(t, domain, svc.Domain)
}
// TestSynthesizeServiceForDomain_LabelMatchesButParentDoesNot — the label is
// globally unique, so a lookup by first label can hit a row that does NOT own
// the queried hostname. That must resolve to nothing rather than to the wrong
// account's service.
func TestSynthesizeServiceForDomain_LabelMatchesButParentDoesNot(t *testing.T) {
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
settings := newSynthTestSettings()
settings.Cluster = "eu.proxy.netbird.io"
settings.Zone = "gateway.netbird.ai"
settings.Subdomain = "brave-otter"
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
provider := newSynthTestProvider()
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
svc, err := SynthesizeServiceForDomain(ctx, s, "brave-otter.someone-elses.zone")
require.NoError(t, err)
assert.Nil(t, svc, "label matched a different endpoint's parent; must not resolve to the wrong account")
}
// TestSynthesizeServiceForDomain_UnknownLabel — a hostname whose first label
// belongs to no account is a miss, not an error: the caller falls back to the
// persisted-service lookup and a returned error would mask that.
func TestSynthesizeServiceForDomain_UnknownLabel(t *testing.T) {
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
svc, err := SynthesizeServiceForDomain(ctx, s, "nobody-home.gateway.netbird.ai")
require.NoError(t, err)
assert.Nil(t, svc, "unknown label must be a miss, not an error")
}
// TestSynthesizeServiceForDomain_DegenerateInput — empty and single-label
// hostnames have no dot to cut a subdomain label from, so they resolve to no
// service, same as any other unowned hostname. The early-return guard that
// catches them is an optimisation (it skips a store round trip that would
// only miss anyway), not what makes this case correct — "" and "localhost"
// would still come back nil, nil even without it, via the same not-found
// fallthrough TestSynthesizeServiceForDomain_UnknownLabel exercises.
func TestSynthesizeServiceForDomain_DegenerateInput(t *testing.T) {
ctx := context.Background()
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
for _, domain := range []string{"", "localhost"} {
svc, err := SynthesizeServiceForDomain(ctx, s, domain)
require.NoError(t, err, "domain %q", domain)
assert.Nil(t, svc, "domain %q has no subdomain label to look up", domain)
}
}

View File

@@ -61,7 +61,7 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
Return(true, context.Background(), nil).
AnyTimes()
manager := agentnetwork.NewManager(st, perms, nil, nil)
manager := agentnetwork.NewManager(st, perms, nil, nil, "")
h := &handler{manager: manager}
router := mux.NewRouter()

View File

@@ -0,0 +1,39 @@
// Package labelgen produces DNS-safe Agent Network subdomain labels.
//
// The adjective pool below pairs with the noun pool in words.go to form
// `<adjective>-<noun>` labels. It 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).
package labelgen
// adjectives is the descriptor half of a generated label.
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

@@ -2,18 +2,11 @@
package labelgen
import (
"fmt"
"math/rand"
"sort"
"sync"
)
// pickAttempts caps the random retries before falling back to the
// suffixed form. Eight is a soft compromise: with a near-empty taken
// set the very first pick almost always succeeds; when the wordlist is
// densely populated the fallback eventually fires anyway.
const pickAttempts = 8
var (
dedupOnce sync.Once
uniqWords []string
@@ -37,30 +30,19 @@ func uniqueWords() []string {
return uniqWords
}
// PickUnique selects a label not already in `taken`. It tries up to
// pickAttempts random picks; on exhaustion it scans the deduplicated
// wordlist for any remaining free entry, and if none is left appends
// `-<fallbackSuffix>` to a deterministic word and returns. The caller
// is responsible for seeding rng (math/rand).
func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string {
pool := uniqueWords()
if len(pool) == 0 {
return fallbackSuffix
// PickTuple returns an adjective-noun label such as "brave-otter". It is still
// a single DNS label.
//
// 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 ""
}
for i := 0; i < pickAttempts; i++ {
w := pool[rng.Intn(len(pool))]
if _, ok := taken[w]; !ok {
return w
}
}
for _, w := range pool {
if _, ok := taken[w]; !ok {
return w
}
}
w := pool[rng.Intn(len(pool))]
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
return adjectives[rng.Intn(len(adjectives))] + "-" + nouns[rng.Intn(len(nouns))]
}

View File

@@ -9,78 +9,6 @@ import (
"github.com/stretchr/testify/require"
)
// TestPickUnique_DeterministicWithSeededRng locks the property the
// caller relies on: same seed + same taken set → same pick. Without
// that, the bootstrap flow can't reproduce a label across retries.
func TestPickUnique_DeterministicWithSeededRng(t *testing.T) {
taken := map[string]struct{}{}
rngA := rand.New(rand.NewSource(42))
rngB := rand.New(rand.NewSource(42))
a := PickUnique(rngA, taken, "abcd")
b := PickUnique(rngB, taken, "abcd")
assert.Equal(t, a, b, "Same seed and taken set must produce identical pick")
}
// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
// every word in the pool except a handful and confirms PickUnique
// finds one of the remaining free entries instead of returning the
// fallback form.
func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
pool := uniqueWords()
require.NotEmpty(t, pool, "wordlist must be populated for the test to mean anything")
free := map[string]struct{}{
pool[0]: {},
pool[len(pool)/2]: {},
pool[len(pool)-1]: {},
}
taken := make(map[string]struct{}, len(pool))
for _, w := range pool {
if _, ok := free[w]; ok {
continue
}
taken[w] = struct{}{}
}
rng := rand.New(rand.NewSource(7))
got := PickUnique(rng, taken, "abcd")
_, isFree := free[got]
assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
assert.NotContains(t, got, "-", "Free pick must not be the suffix fallback form")
}
// TestPickUnique_FallsBackWhenAllReserved exhausts the pool and
// confirms PickUnique appends the supplied suffix instead of
// returning a duplicate.
func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
pool := uniqueWords()
taken := make(map[string]struct{}, len(pool))
for _, w := range pool {
taken[w] = struct{}{}
}
rng := rand.New(rand.NewSource(99))
got := PickUnique(rng, taken, "abcd")
assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce <word>-<suffix>; got %q", got)
prefix := strings.TrimSuffix(got, "-abcd")
found := false
for _, w := range pool {
if w == prefix {
found = true
break
}
}
assert.True(t, found, "Fallback prefix must be drawn from the wordlist; got %q", prefix)
}
// TestUniqueWords_DropsDuplicates guards against authoring slips in
// words.go: every entry must be unique and DNS-safe.
func TestUniqueWords_DropsDuplicates(t *testing.T) {
@@ -99,3 +27,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

@@ -6,7 +6,7 @@
// hand-checked to avoid offensive, brand, or region-specific terms.
package labelgen
// words is the pool PickUnique selects from. The slice is intentionally
// words is the pool PickTuple draws its noun from. The slice is intentionally
// not sorted — random picks distribute across the list naturally.
var words = []string{
"acorn", "adobe", "agate", "alder", "almond", "alpine", "amber", "amethyst",

View File

@@ -122,6 +122,10 @@ type managerImpl struct {
permissionsManager permissions.Manager
proxyController proxy.Controller
// zone is the parent DNS zone stamped onto newly allocated settings rows.
// Empty keeps the legacy <subdomain>.<cluster> endpoint form.
zone string
// 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
@@ -129,7 +133,7 @@ type managerImpl struct {
reconcileMu sync.Mutex
reconcileCache map[string]map[string]*proto.ProxyMapping
// labelRngMu guards labelRng. PickUnique consumes math/rand.Source
// labelRngMu guards labelRng. PickTuple consumes math/rand.Source
// state; concurrent provider creates would otherwise race.
labelRngMu sync.Mutex
labelRng *rand.Rand
@@ -145,26 +149,28 @@ func NewManager(
permissionsManager permissions.Manager,
accountManager account.Manager,
proxyController proxy.Controller,
zone string,
) Manager {
return &managerImpl{
store: store,
accountManager: accountManager,
permissionsManager: permissionsManager,
proxyController: proxyController,
zone: zone,
reconcileCache: make(map[string]map[string]*proto.ProxyMapping),
labelRng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
@@ -175,9 +181,14 @@ func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, provid
// 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) {
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Create); err != nil {
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
@@ -218,7 +229,7 @@ func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provide
}
func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) {
if err := m.requirePermission(ctx, provider.AccountID, userID, operations.Update); err != nil {
if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Update); err != nil {
return nil, err
}
@@ -257,7 +268,7 @@ func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provide
}
func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Delete); err != nil {
return err
}
@@ -298,6 +309,22 @@ func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, pro
return nil
}
// isUniqueConstraintError reports whether err is a duplicate-key rejection.
//
// The equivalent helper in management/server is unexported, so it cannot be
// reused from here; this is a deliberate duplicate rather than a new dependency
// on that package for a single three-line matcher. Keep the two in sync if a
// dialect is added.
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
}
func pluralize(n int, singular, plural string) string {
if n == 1 {
return singular
@@ -306,21 +333,21 @@ func pluralize(n int, singular, plural string) string {
}
func (m *managerImpl) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID)
}
func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Create); err != nil {
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Create); err != nil {
return nil, err
}
@@ -346,7 +373,7 @@ func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *t
}
func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) {
if err := m.requirePermission(ctx, policy.AccountID, userID, operations.Update); err != nil {
if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Update); err != nil {
return nil, err
}
@@ -373,7 +400,7 @@ func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *t
}
func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, policyID string) error {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Delete); err != nil {
return err
}
@@ -393,21 +420,21 @@ func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, polic
}
func (m *managerImpl) GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID)
}
func (m *managerImpl) GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID)
}
func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Create); err != nil {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Create); err != nil {
return nil, err
}
@@ -429,7 +456,7 @@ func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardr
}
func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, operations.Update); err != nil {
if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Update); err != nil {
return nil, err
}
@@ -452,7 +479,7 @@ func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardr
}
func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Delete); err != nil {
return err
}
@@ -473,7 +500,7 @@ func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, gu
// GetAllBudgetRules returns every account-level budget rule for the account.
func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
return nil, err
}
return m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID)
@@ -481,7 +508,7 @@ func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID s
// GetBudgetRule returns a single account-level budget rule.
func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthNone, accountID, ruleID)
@@ -491,7 +518,7 @@ func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, rule
// enforced at request time (CheckLLMPolicyLimits), not baked into the synth
// proxy config, so no reconcile is needed.
func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Create); err != nil {
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Create); err != nil {
return nil, err
}
@@ -513,7 +540,7 @@ func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule
// UpdateBudgetRule updates an existing account-level budget rule.
func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) {
if err := m.requirePermission(ctx, rule.AccountID, userID, operations.Update); err != nil {
if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Update); err != nil {
return nil, err
}
@@ -536,7 +563,7 @@ func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule
// DeleteBudgetRule removes an account-level budget rule.
func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error {
if err := m.requirePermission(ctx, accountID, userID, operations.Delete); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Delete); err != nil {
return err
}
@@ -561,7 +588,7 @@ func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, r
// 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) {
if err := m.requirePermission(ctx, settings.AccountID, userID, operations.Update); err != nil {
if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil {
return nil, err
}
@@ -615,18 +642,37 @@ func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string
// Returns the underlying status.NotFound when no row has been
// bootstrapped yet (i.e. the account has no providers).
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Read); err != nil {
return nil, err
}
return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
}
// 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.
// 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
}
var sErr *status.Error
if !errors.As(err, &sErr) || sErr.Type() != status.NotFound {
return fmt.Errorf("get agent network settings: %w", err)
}
return m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Create)
}
// maxSubdomainAllocationAttempts bounds the allocate-and-insert retry loop in
// bootstrapSettingsIfNeeded. Package-level (rather than function-local) so
// tests can assert on the exhaustion path without duplicating the literal.
const maxSubdomainAllocationAttempts = 10
// bootstrapSettingsIfNeeded creates the per-account agent-network settings
// row when missing, allocating a subdomain unique across the whole zone.
// Idempotent: if a row already exists it is returned untouched and the
// cluster hint is ignored.
func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) {
if accountID == "" {
return nil, fmt.Errorf("bootstrap settings: account id is required")
@@ -644,40 +690,66 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
return nil, fmt.Errorf("get agent network settings: %w", err)
}
siblings, err := m.store.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()
// Labels must be unique across the whole zone; the database's unique index
// enforces that, and the loop below retries with a fresh label whenever an
// attempt is rejected.
now := time.Now().UTC()
settings := &types.Settings{
AccountID: accountID,
Cluster: providerCluster,
Subdomain: subdomain,
// Logs on by default; usage is collected regardless. Retention bounds
// how long full log rows are kept.
AccountID: accountID,
Cluster: providerCluster,
Zone: m.zone,
EnableLogCollection: true,
AccessLogRetentionDays: types.DefaultAccessLogRetentionDays,
CreatedAt: now,
UpdatedAt: now,
}
if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil {
return nil, fmt.Errorf("save agent network settings: %w", err)
for attempt := 1; attempt <= maxSubdomainAllocationAttempts; attempt++ {
m.labelRngMu.Lock()
settings.Subdomain = labelgen.PickTuple(m.labelRng)
m.labelRngMu.Unlock()
if settings.Subdomain == "" {
// Only reachable if either word pool were emptied; a database
// insert of an empty subdomain would collide with the unique
// index in a confusing way and produce a broken endpoint like
// ".gateway.example". Fail loudly instead of looping or inserting.
return nil, fmt.Errorf(
"allocate agent network subdomain for account %s: label generator returned an empty label",
accountID)
}
// Each attempt gets its own transaction wrapping a single INSERT: on
// postgres a failed statement poisons the enclosing transaction, so a
// fresh transaction per attempt is what makes the retry loop work on
// that dialect at all.
err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
return transaction.CreateAgentNetworkSettings(ctx, settings)
})
if err == nil {
return settings, nil
}
if isUniqueConstraintError(err) {
// A concurrent bootstrap for this account may have won the race: the
// pre-check above is outside the transaction, and the settings PK is
// account_id, so the loser's insert fails on the primary key rather
// than the subdomain index. Re-read before assuming the label was
// taken, so a same-account race resolves immediately instead of
// burning every remaining attempt on the same primary-key conflict.
if existing, getErr := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID); getErr == nil {
return existing, nil
}
log.WithContext(ctx).Tracef(
"agent-network subdomain %q taken, retrying (attempt %d/%d)",
settings.Subdomain, attempt, maxSubdomainAllocationAttempts)
continue
}
return nil, fmt.Errorf("create agent network settings: %w", err)
}
return settings, nil
return nil, fmt.Errorf(
"allocate agent network subdomain for account %s: %d attempts exhausted",
accountID, maxSubdomainAllocationAttempts)
}
// ListConsumption returns every consumption row recorded for the
@@ -685,7 +757,7 @@ func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID,
// counter view; permission gate is the same Read role that gates
// every other agent-network surface.
func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
return nil, err
}
return m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID)
@@ -694,7 +766,7 @@ func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID str
// ListAccessLogs returns a paginated, server-side-filtered page of
// agent-network access logs plus the total count matching the filter.
func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter)
@@ -704,7 +776,7 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri
// agent-network access logs grouped by session, plus the total number of
// sessions matching the filter.
func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkLogs, operations.Read); err != nil {
return nil, 0, err
}
return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter)
@@ -713,7 +785,7 @@ func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, user
// GetUsageOverview returns the filtered usage rows aggregated into time buckets
// at the requested granularity, oldest-first.
func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) {
if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil {
if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkUsage, operations.Read); err != nil {
return nil, err
}
rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter)
@@ -787,8 +859,8 @@ func (m *managerImpl) RecordConsumption(ctx context.Context, accountID string, k
return m.store.IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD)
}
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, op operations.Operation) error {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetwork, op)
func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, module modules.Module, op operations.Operation) error {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, module, op)
if err != nil {
return status.NewPermissionValidationError(err)
}

View File

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

@@ -116,45 +116,46 @@ 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.
// endpoint hostname. Both endpoint shapes put the account's label in the first
// DNS label — <subdomain>.<cluster> and <subdomain>.<zone> — and the label is
// globally unique, so this is a single indexed lookup for either shape. It
// synthesises only the owning account rather than every tenant on a cluster,
// which is what auth/session paths previously paid. Returns nil (no error) when
// no account owns the hostname.
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)
}
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
}
subdomain, _, found := strings.Cut(domain, ".")
if !found || subdomain == "" {
return nil, nil //nolint:nilnil // no label to resolve: not an owned endpoint
}
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:]
settings, err := s.GetAgentNetworkSettingsBySubdomain(ctx, store.LockingStrengthNone, subdomain)
if err != nil {
var sErr *status.Error
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
return nil, nil //nolint:nilnil // no account owns the label
}
// A real store failure must surface: the caller treats nil as "not an
// agent-network endpoint" and would silently mask a database error.
return nil, fmt.Errorf("get agent network settings by subdomain: %w", err)
}
return ""
// The label is unique but the parent is not implied by it: a row owning
// "brave-otter" does not own "brave-otter.some-other.zone".
if settings.Endpoint() != domain {
return nil, nil //nolint:nilnil // label matched a different endpoint
}
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 // owner found but it emits no service
}
// SynthesizeServices builds the in-memory reverse-proxy service that
@@ -944,6 +945,7 @@ func buildAccountService(
Name: "agent-network-" + accountID,
Domain: domain,
ProxyCluster: cluster,
DNSZone: settings.Zone, // empty for legacy rows → unchanged behavior
Mode: rpservice.ModeHTTP,
Enabled: true,
Private: true,

View File

@@ -18,6 +18,14 @@ type Settings struct {
AccountID string `gorm:"primaryKey"`
Cluster string
Subdomain string `gorm:"index:idx_agent_network_settings_cluster_subdomain"`
// Zone is the placement-independent parent zone the endpoint lives under,
// captured from server config when the row is allocated. Immutable, like
// Cluster and Subdomain.
//
// Empty means "legacy": the endpoint falls back to <subdomain>.<cluster>,
// which embeds the serving proxy. Existing rows and any deployment that
// configures no zone keep that behaviour unchanged.
Zone string
// Account-level collection controls sourced by the synthesizer.
// EnableLogCollection gates the per-request access-log trail and defaults
@@ -42,9 +50,17 @@ type Settings struct {
// schema cohesive.
func (Settings) TableName() string { return "agent_network_settings" }
// Endpoint returns the bare hostname agents reach this account at:
// `<subdomain>.<cluster>`.
// Endpoint returns the bare hostname agents reach this account at.
//
// With a Zone set this is `<subdomain>.<zone>` — deliberately independent of
// which proxy serves the account, so moving between a shared and a private
// proxy (or between clusters) is a DNS change only and never alters the
// tenant's address. With no Zone it falls back to the legacy
// `<subdomain>.<cluster>` form.
func (s *Settings) Endpoint() string {
if s.Zone != "" {
return s.Subdomain + "." + s.Zone
}
return s.Subdomain + "." + s.Cluster
}

View File

@@ -0,0 +1,31 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestEndpoint_PrefersZoneOverCluster locks the decoupling: when a Zone is set
// the hostname must NOT embed the serving cluster, so moving a tenant between
// proxies never changes their address.
func TestEndpoint_PrefersZoneOverCluster(t *testing.T) {
s := &Settings{Subdomain: "brave-otter", Cluster: "eu.proxy.netbird.io", Zone: "gateway.netbird.ai"}
assert.Equal(t, "brave-otter.gateway.netbird.ai", s.Endpoint())
}
// TestEndpoint_FallsBackToClusterWhenZoneEmpty is the compatibility guarantee:
// existing rows (and every self-hosted deployment, which sets no zone) keep
// exactly the address they have today.
func TestEndpoint_FallsBackToClusterWhenZoneEmpty(t *testing.T) {
s := &Settings{Subdomain: "otter", Cluster: "eu.proxy.netbird.io"}
assert.Equal(t, "otter.eu.proxy.netbird.io", s.Endpoint())
}
// TestToAPIResponse_ExposesZoneAndDerivedEndpoint — the dashboard renders
// Endpoint verbatim, so it must reflect the zone.
func TestToAPIResponse_ExposesZoneAndDerivedEndpoint(t *testing.T) {
s := &Settings{Subdomain: "brave-otter", Cluster: "eu.proxy.netbird.io", Zone: "gateway.netbird.ai"}
resp := s.ToAPIResponse()
assert.Equal(t, "brave-otter.gateway.netbird.ai", resp.Endpoint)
}

View File

@@ -255,6 +255,13 @@ type Service struct {
Private bool
// AccessGroups is the group ID allowlist for inbound peers on private services. Mutually exclusive with bearer SSO.
AccessGroups []string `json:"access_groups,omitempty" gorm:"serializer:json"`
// DNSZone is the parent zone a private service's synthesized mesh A record
// hangs under, for the case where that zone cannot be derived from
// ProxyCluster or a validated custom domain — i.e. placement-free
// agent-network endpoints, which are <subdomain>.<zone>. In-memory only:
// set by the agent-network synthesizer on services it builds per read,
// never stored and never exposed on the API or the proxy wire.
DNSZone string `gorm:"-" json:"-"`
}
// InitNewRecord generates a new unique ID and resets metadata for a newly created
@@ -1412,6 +1419,7 @@ func (s *Service) Copy() *Service {
PortAutoAssigned: s.PortAutoAssigned,
Private: s.Private,
AccessGroups: accessGroups,
DNSZone: s.DNSZone,
}
}

View File

@@ -1215,6 +1215,17 @@ func TestService_Copy_RoundtripsPrivate(t *testing.T) {
assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups)
}
// TestServiceCopy_PreservesDNSZone — DNSZone is in-memory only, so it is easy
// to omit from Copy()'s explicit field list; if it is dropped, a copied
// account silently loses its zone apex and the tenant's endpoint resolves to
// nothing.
func TestServiceCopy_PreservesDNSZone(t *testing.T) {
svc := &Service{Domain: "brave-otter.gateway.netbird.ai", DNSZone: "gateway.netbird.ai"}
cp := svc.Copy()
require.NotNil(t, cp)
assert.Equal(t, "gateway.netbird.ai", cp.DNSZone)
}
func TestService_APIRoundtrip_Private(t *testing.T) {
enabled := true
private := true

View File

@@ -24,13 +24,13 @@ import (
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/formatter/hook"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/activity"
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
nbcache "github.com/netbirdio/netbird/management/server/cache"
nbContext "github.com/netbirdio/netbird/management/server/context"
nbhttp "github.com/netbirdio/netbird/management/server/http"
@@ -184,6 +184,10 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor, proxyStream),
}
// Append interceptors contributed by registered gRPC extensions. These
// run after the built-in chain (ChainUnaryInterceptor is additive).
gRPCOpts = appendExtensionInterceptors(gRPCOpts, s.grpcExtensions)
if s.Config.HttpConfig.LetsEncryptDomain != "" {
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
if err != nil {
@@ -215,6 +219,9 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer())
log.Info("ProxyService registered on gRPC server")
// Register services contributed by external modules via the extension seam.
registerExtensions(gRPCAPIHandler, s.grpcExtensions)
return gRPCAPIHandler
})
}

View File

@@ -204,6 +204,15 @@ type AgentNetwork struct {
// prefill with). An explicitly configured path that fails to load
// fails startup; runtime reload errors keep the previous table.
PricingDefaultsFile string
// Zone is the parent DNS zone that Agent Network gateway endpoints are
// allocated under, producing <subdomain>.<zone>.
//
// Empty (the default) preserves the legacy behaviour of deriving the
// endpoint from the serving cluster, so self-hosted deployments are
// unaffected. It is captured onto each settings row when that row is
// created; changing it later does not move existing tenants.
Zone string
}
// ReverseProxy contains reverse proxy configuration in front of management.

View File

@@ -0,0 +1,74 @@
package server
import (
"context"
"google.golang.org/grpc"
)
// GRPCExtension bundles an external module's contribution to the management
// gRPC server: the registration of one or more services onto the shared
// grpc.Server, any server-wide interceptors those services require, and an
// optional shutdown hook. It is a generic extension point with no knowledge of
// any specific service.
type GRPCExtension struct {
// Register is invoked with the shared grpc.Server (as a ServiceRegistrar)
// after the built-in services are registered. It may register any number of
// services. May be nil.
Register func(grpc.ServiceRegistrar)
// UnaryInterceptors are appended to the server's unary interceptor chain,
// running after the built-in interceptors. May be empty.
UnaryInterceptors []grpc.UnaryServerInterceptor
// StreamInterceptors are appended to the server's stream interceptor chain,
// running after the built-in interceptors. May be empty.
StreamInterceptors []grpc.StreamServerInterceptor
// Shutdown, if non-nil, is called once during Stop() with the context
// governing server shutdown, which carries a deadline. The hook MUST
// return promptly and MUST abandon its work once that context is
// cancelled or expires: it runs before the rest of Stop()'s cleanup
// (store, event store, embedded IdP) and before Stop() itself checks the
// context's deadline, so a hook that ignores the context will delay all
// of that cleanup and prevent Stop() from returning on time. May be nil.
Shutdown func(ctx context.Context)
}
// RegisterGRPCExtension registers a gRPC extension. Call before the gRPC server
// is first built (i.e. before Start); registrations after that have no effect.
func (s *BaseServer) RegisterGRPCExtension(ext GRPCExtension) {
s.grpcExtensions = append(s.grpcExtensions, ext)
}
// appendExtensionInterceptors appends each extension's interceptors to the gRPC
// server options as additional chained interceptors. grpc.ChainUnaryInterceptor
// and grpc.ChainStreamInterceptor are additive, so the returned options run the
// extension interceptors after any interceptors already present in opts.
func appendExtensionInterceptors(opts []grpc.ServerOption, exts []GRPCExtension) []grpc.ServerOption {
for _, ext := range exts {
if len(ext.UnaryInterceptors) > 0 {
opts = append(opts, grpc.ChainUnaryInterceptor(ext.UnaryInterceptors...))
}
if len(ext.StreamInterceptors) > 0 {
opts = append(opts, grpc.ChainStreamInterceptor(ext.StreamInterceptors...))
}
}
return opts
}
// registerExtensions registers each extension's services onto reg.
func registerExtensions(reg grpc.ServiceRegistrar, exts []GRPCExtension) {
for _, ext := range exts {
if ext.Register != nil {
ext.Register(reg)
}
}
}
// runExtensionShutdownHooks calls each extension's shutdown hook, if set,
// passing ctx through so hooks can honor its deadline/cancellation.
func runExtensionShutdownHooks(ctx context.Context, exts []GRPCExtension) {
for _, ext := range exts {
if ext.Shutdown != nil {
ext.Shutdown(ctx)
}
}
}

View File

@@ -0,0 +1,160 @@
package server
import (
"context"
"net"
"sync/atomic"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/test/bufconn"
)
// Test that an extension's interceptors and service registration are actually
// wired onto a real in-process gRPC server via the helpers, and that shutdown
// hooks run. This validates the load-bearing assumption that
// grpc.ChainUnaryInterceptor is additive (extension interceptors run in
// addition to any base chain).
func TestGRPCExtensionAppliedToServer(t *testing.T) {
var unaryCalls atomic.Int32
var streamShutdownCalled atomic.Bool
ext := GRPCExtension{
Register: func(reg grpc.ServiceRegistrar) {
healthgrpc.RegisterHealthServer(reg, health.NewServer())
},
UnaryInterceptors: []grpc.UnaryServerInterceptor{
func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
unaryCalls.Add(1)
return handler(ctx, req)
},
},
Shutdown: func(ctx context.Context) { streamShutdownCalled.Store(true) },
}
exts := []GRPCExtension{ext}
// Base options mimic GRPCServer(): a pre-existing chain the extension appends to.
var baseUnaryCalls atomic.Int32
opts := []grpc.ServerOption{
grpc.ChainUnaryInterceptor(func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
baseUnaryCalls.Add(1)
return handler(ctx, req)
}),
}
opts = appendExtensionInterceptors(opts, exts)
srv := grpc.NewServer(opts...)
registerExtensions(srv, exts)
lis := bufconn.Listen(1024 * 1024)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
_, err = healthgrpc.NewHealthClient(conn).Check(context.Background(), &healthgrpc.HealthCheckRequest{})
if err != nil {
t.Fatalf("health check via extension-registered service failed: %v", err)
}
if baseUnaryCalls.Load() != 1 {
t.Errorf("base interceptor calls = %d, want 1 (base chain must be preserved)", baseUnaryCalls.Load())
}
if unaryCalls.Load() != 1 {
t.Errorf("extension interceptor calls = %d, want 1", unaryCalls.Load())
}
runExtensionShutdownHooks(context.Background(), exts)
if !streamShutdownCalled.Load() {
t.Error("extension shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookReceivesCallerContext asserts that each hook receives
// a non-nil context and that it is the very same context the caller passed
// in, so hooks can rely on values/deadlines placed on it by Stop().
func TestGRPCExtensionShutdownHookReceivesCallerContext(t *testing.T) {
type sentinelKey struct{}
want := "shutdown-ctx-sentinel"
ctx := context.WithValue(context.Background(), sentinelKey{}, want)
var called bool
ext := GRPCExtension{
Shutdown: func(hookCtx context.Context) {
called = true
if hookCtx == nil {
t.Fatal("hook received a nil context")
}
got, _ := hookCtx.Value(sentinelKey{}).(string)
if got != want {
t.Errorf("hook context sentinel = %q, want %q (not the caller's context)", got, want)
}
},
}
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
if !called {
t.Fatal("shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookObservesCancellation documents, by test, that
// hooks can honor cancellation/deadlines: a hook given an already-cancelled
// context must see ctx.Err() != nil and a closed Done() channel.
func TestGRPCExtensionShutdownHookObservesCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
var called bool
ext := GRPCExtension{
Shutdown: func(hookCtx context.Context) {
called = true
if hookCtx.Err() == nil {
t.Error("hook context Err() = nil, want non-nil for a cancelled context")
}
select {
case <-hookCtx.Done():
default:
t.Error("hook context Done() channel is not closed for a cancelled context")
}
},
}
runExtensionShutdownHooks(ctx, []GRPCExtension{ext})
if !called {
t.Fatal("shutdown hook was not called")
}
}
// TestGRPCExtensionShutdownHookNilSkipped asserts that an extension
// with a nil Shutdown hook is skipped without panicking, and that hooks for
// other extensions still run.
func TestGRPCExtensionShutdownHookNilSkipped(t *testing.T) {
var called atomic.Bool
exts := []GRPCExtension{
{Shutdown: nil},
{Shutdown: func(context.Context) { called.Store(true) }},
}
runExtensionShutdownHooks(context.Background(), exts)
if !called.Load() {
t.Error("shutdown hook for non-nil extension was not called")
}
}
func TestRegisterGRPCExtensionAccumulates(t *testing.T) {
s := &BaseServer{}
s.RegisterGRPCExtension(GRPCExtension{})
s.RegisterGRPCExtension(GRPCExtension{})
if len(s.grpcExtensions) != 2 {
t.Fatalf("grpcExtensions len = %d, want 2", len(s.grpcExtensions))
}
}

View File

@@ -202,6 +202,7 @@ func (s *BaseServer) AgentNetworkManager() agentnetwork.Manager {
s.PermissionsManager(),
s.AccountManager(),
s.ServiceProxyController(),
s.Config.AgentNetwork.Zone,
)
// Sweep expired agent-network access logs per account retention,
// reusing the reverse-proxy cleanup interval config.

View File

@@ -68,6 +68,11 @@ type BaseServer struct {
proxyAuthClose func()
// grpcExtensions holds additional gRPC services, interceptors, and shutdown
// hooks registered by external modules via RegisterGRPCExtension. Populated
// during boot (single-threaded), consumed by GRPCServer() and Stop().
grpcExtensions []GRPCExtension
listener net.Listener
certManager *autocert.Manager
update *version.Update
@@ -257,6 +262,7 @@ func (s *BaseServer) Stop() error {
s.proxyAuthClose()
s.proxyAuthClose = nil
}
runExtensionShutdownHooks(ctx, s.grpcExtensions)
_ = s.Store().Close(ctx)
_ = s.EventStore().Close(ctx)
if s.update != nil {

View File

@@ -61,6 +61,8 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
return &proto.NetworkMapEnvelope{
Payload: &proto.NetworkMapEnvelope_Full{
Full: &proto.NetworkMapComponentsFull{
Serial: networkSerial(c.Network),
Network: toAccountNetwork(c.Network),
PeerConfig: in.PeerConfig,
// components.Peers always contains the target peer
Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])},

View File

@@ -758,6 +758,9 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
assert.Equal(t, "netbird.cloud", full.DnsDomain)
assert.Len(t, full.Peers, 1)
assert.Empty(t, full.Policies)
require.NotNil(t, full.Network, "client runs Calculate() over the envelope and dereferences Network unconditionally; a nil here would crash the receiver")
assert.Equal(t, "net-empty", full.Network.Identifier)
assert.Equal(t, uint64(9), full.Serial)
}
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
@@ -776,6 +779,12 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
func emptyNetworkMapComponents() *types.NetworkMapComponents {
return types.EmptyNetworkMapComponents(
&types.NetworkMapComponents{
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}},
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}},
Network: &types.Network{
Identifier: "net-empty",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 9,
},
},
)
}

View File

@@ -30,7 +30,7 @@ func TestAgentNetwork_BudgetRuleCRUD_RealManager(t *testing.T) {
account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false)
require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed")
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
created, err := mgr.CreateBudgetRule(ctx, adminUserID, &agenttypes.AccountBudgetRule{
AccountID: accountID,
@@ -82,7 +82,7 @@ func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *t
account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false)
require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed")
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
// Creating a provider bootstraps the settings row (cluster + subdomain).
_, err = mgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{

View File

@@ -90,7 +90,7 @@ func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) {
// Real agentnetwork manager wired to the real account manager. proxyController
// is nil (no gRPC cluster fan-out here) — the reconcile still fires
// UpdateAccountPeers, which is the path under test.
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil)
agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil, "")
provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{
AccountID: accountID,

View File

@@ -82,6 +82,9 @@ func (m *managerImpl) ValidateUserPermissions(
return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), ctxEnriched, nil
}
// ValidateRoleModuleAccess resolves an operation against the role's explicit
// grant for the module, then the grant for its parent module when the module
// is a dotted submodule, and finally the role's AutoAllowNew default.
func (m *managerImpl) ValidateRoleModuleAccess(
ctx context.Context,
accountID string,
@@ -89,7 +92,7 @@ func (m *managerImpl) ValidateRoleModuleAccess(
module modules.Module,
operation operations.Operation,
) bool {
if permissions, ok := role.Permissions[module]; ok {
if permissions, ok := lookupModulePermissions(role, module); ok {
if allowed, exists := permissions[operation]; exists {
return allowed
}
@@ -100,6 +103,21 @@ func (m *managerImpl) ValidateRoleModuleAccess(
return role.AutoAllowNew[operation]
}
// lookupModulePermissions returns the role's explicit permission set for the
// module, falling back to the parent module's set for dotted submodules. The
// second return reports whether any explicit set was found.
func lookupModulePermissions(role roles.RolePermissions, module modules.Module) (map[operations.Operation]bool, bool) {
if permissions, ok := role.Permissions[module]; ok {
return permissions, true
}
if parent, hasParent := module.Parent(); hasParent {
if permissions, ok := role.Permissions[parent]; ok {
return permissions, true
}
}
return nil, false
}
func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
if user.AccountID != accountID {
return ctx, status.NewUserNotPartOfAccountError()
@@ -119,7 +137,7 @@ func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserR
permissions := roles.Permissions{}
for k := range modules.All {
if rolePermissions, ok := roleMap.Permissions[k]; ok {
if rolePermissions, ok := lookupModulePermissions(roleMap, k); ok {
permissions[k] = rolePermissions
continue
}

View File

@@ -0,0 +1,139 @@
package permissions
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/permissions/roles"
"github.com/netbirdio/netbird/management/server/types"
)
func TestValidateRoleModuleAccessSubmoduleCascade(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
fullAccess := map[operations.Operation]bool{
operations.Read: true,
operations.Create: true,
operations.Update: true,
operations.Delete: true,
}
readOnly := map[operations.Operation]bool{
operations.Read: true,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
}
denyAll := map[operations.Operation]bool{
operations.Read: false,
operations.Create: false,
operations.Update: false,
operations.Delete: false,
}
t.Run("parent grant covers submodules", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{modules.AgentNetwork: fullAccess},
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Create),
"parent full grant should allow create on a submodule")
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
"parent full grant should allow read on a submodule")
})
t.Run("submodule grant does not leak to parent or siblings", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{modules.AgentNetworkUsage: readOnly},
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
"explicit submodule read should be allowed")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Create),
"read-only submodule grant should not allow create")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetwork, operations.Read),
"submodule grant should not grant the parent module")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
"submodule grant should not grant a sibling submodule")
})
t.Run("explicit submodule entry wins over parent grant", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: denyAll,
Permissions: roles.Permissions{
modules.AgentNetwork: fullAccess,
modules.AgentNetworkLogs: denyAll,
},
}
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkLogs, operations.Read),
"explicit submodule deny should override the parent grant")
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkUsage, operations.Read),
"sibling submodules should still resolve through the parent grant")
})
t.Run("auto allow applies when neither submodule nor parent is granted", func(t *testing.T) {
role := roles.RolePermissions{
AutoAllowNew: readOnly,
}
assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Read),
"auto-allow read should apply to submodules")
assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, modules.AgentNetworkProviders, operations.Delete),
"auto-allow should not grant unlisted operations")
})
}
// TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules pins the behavior the
// submodule split must not change: every built-in role resolves the new
// submodules exactly as it resolved the agent_network module before.
func TestExistingRolesKeepAgentNetworkBehaviorOnSubmodules(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
submodules := []modules.Module{
modules.AgentNetworkProviders,
modules.AgentNetworkPolicies,
modules.AgentNetworkGuardrails,
modules.AgentNetworkBudgets,
modules.AgentNetworkUsage,
modules.AgentNetworkLogs,
modules.AgentNetworkSettings,
}
allOperations := []operations.Operation{operations.Read, operations.Create, operations.Update, operations.Delete}
for _, role := range []types.UserRole{types.UserRoleOwner, types.UserRoleAdmin, types.UserRoleAuditor, types.UserRoleNetworkAdmin, types.UserRoleUser} {
rolePermissions, ok := roles.RolesMap[role]
require.True(t, ok, "role %s must exist in RolesMap", role)
for _, sub := range submodules {
for _, op := range allOperations {
expected := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, modules.AgentNetwork, op)
actual := manager.ValidateRoleModuleAccess(ctx, "account", rolePermissions, sub, op)
assert.Equal(t, expected, actual, "role %s: %s on %s should match the agent_network module", role, op, sub)
}
}
}
}
func TestGetPermissionsByRoleIncludesSubmodules(t *testing.T) {
manager := NewManager(nil)
ctx := context.Background()
permissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAuditor)
require.NoError(t, err, "auditor role must resolve")
usage, ok := permissions[modules.AgentNetworkUsage]
require.True(t, ok, "permissions map should contain the usage submodule")
assert.True(t, usage[operations.Read], "auditor should read the usage submodule")
assert.False(t, usage[operations.Update], "auditor should not update the usage submodule")
adminPermissions, err := manager.GetPermissionsByRole(ctx, types.UserRoleAdmin)
require.NoError(t, err, "admin role must resolve")
providers, ok := adminPermissions[modules.AgentNetworkProviders]
require.True(t, ok, "permissions map should contain the providers submodule")
assert.True(t, providers[operations.Delete], "admin should delete on the providers submodule")
}

View File

@@ -1,5 +1,7 @@
package modules
import "strings"
type Module string
const (
@@ -20,6 +22,17 @@ const (
IdentityProviders Module = "identity_providers"
Services Module = "services"
AgentNetwork Module = "agent_network"
// Agent Network submodules. A role may grant one of these directly
// or grant the AgentNetwork parent, which covers all of them (see
// permissions.Manager cascade resolution).
AgentNetworkProviders Module = "agent_network.providers"
AgentNetworkPolicies Module = "agent_network.policies"
AgentNetworkGuardrails Module = "agent_network.guardrails"
AgentNetworkBudgets Module = "agent_network.budgets"
AgentNetworkUsage Module = "agent_network.usage"
AgentNetworkLogs Module = "agent_network.logs"
AgentNetworkSettings Module = "agent_network.settings"
)
var All = map[Module]struct{}{
@@ -40,4 +53,21 @@ var All = map[Module]struct{}{
IdentityProviders: {},
Services: {},
AgentNetwork: {},
AgentNetworkProviders: {},
AgentNetworkPolicies: {},
AgentNetworkGuardrails: {},
AgentNetworkBudgets: {},
AgentNetworkUsage: {},
AgentNetworkLogs: {},
AgentNetworkSettings: {},
}
// Parent returns the module owning a dotted submodule name and true, or the
// module itself and false when it has no parent.
func (m Module) Parent() (Module, bool) {
if i := strings.IndexByte(string(m), '.'); i > 0 {
return Module(string(m)[:i]), true
}
return m, false
}

View File

@@ -334,6 +334,30 @@ func (s *SqlStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStr
return settings, nil
}
// GetAgentNetworkSettingsBySubdomain returns the settings row that owns the
// given subdomain label. The label is globally unique (enforced by
// idx_agent_network_settings_subdomain_unique), so at most one row can match,
// which makes this an indexed point lookup rather than a scan.
func (s *SqlStore) GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*agentNetworkTypes.Settings, error) {
tx := s.db
if lockStrength != LockingStrengthNone {
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
}
var settings agentNetworkTypes.Settings
result := tx.Take(&settings, "subdomain = ?", subdomain)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, status.Errorf(status.NotFound, "agent network settings for subdomain %s not found", subdomain)
}
log.WithContext(ctx).Errorf("failed to get agent network settings by subdomain from store: %v", result.Error)
return nil, status.Errorf(status.Internal, "failed to get agent network settings by subdomain from store")
}
return &settings, nil
}
// SaveAgentNetworkSettings upserts the per-account Agent Network
// settings row.
func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
@@ -346,6 +370,21 @@ func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agent
return nil
}
// CreateAgentNetworkSettings inserts a new settings row.
//
// Unlike SaveAgentNetworkSettings (an upsert) this is a plain INSERT, and it
// returns the driver error unwrapped. Both properties are required by the
// subdomain allocator: it relies on the unique index rejecting a duplicate
// label, and on being able to recognise that rejection so it can retry with a
// fresh label instead of surfacing an error.
func (s *SqlStore) CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error {
if err := s.db.Create(settings).Error; err != nil {
log.WithContext(ctx).Debugf("failed to create agent network settings: %v", err)
return err
}
return nil
}
// IncrementAgentNetworkConsumption atomically upserts the consumption
// row keyed on (account, dim_kind, dim_id, window_seconds, window_start)
// and adds the supplied deltas. Concurrent calls from multiple proxy

View File

@@ -0,0 +1,77 @@
package store
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
)
// TestAgentNetworkSettings_SubdomainIsGloballyUnique is the guard for the whole
// allocation scheme: the label is now globally unique rather than per-cluster,
// and the allocator depends on the DATABASE saying no. Two different accounts on
// two different clusters must not be able to hold the same subdomain.
func TestAgentNetworkSettings_SubdomainIsGloballyUnique(t *testing.T) {
ctx := context.Background()
s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
first := &agentNetworkTypes.Settings{
AccountID: "acc-unique-1",
Cluster: "eu.proxy.example",
Subdomain: "brave-otter",
Zone: "gateway.example",
}
require.NoError(t, s.CreateAgentNetworkSettings(ctx, first), "first insert must succeed")
// Deliberately a different account AND a different cluster: under the old
// per-cluster scheme this was legal, and it is exactly what must now fail.
second := &agentNetworkTypes.Settings{
AccountID: "acc-unique-2",
Cluster: "us.proxy.example",
Subdomain: "brave-otter",
Zone: "gateway.example",
}
err = s.CreateAgentNetworkSettings(ctx, second)
require.Error(t, err, "duplicate subdomain must be rejected by the unique index")
// The allocator recognises conflicts by matching the driver's message, so an
// error that does not carry a unique-violation signature is useless to it
// even though it is non-nil. These are the three signatures management's
// isUniqueConstraintError matches (postgres / mysql / sqlite).
msg := err.Error()
assert.True(t,
strings.Contains(msg, "(SQLSTATE 23505)") ||
strings.Contains(msg, "Error 1062 (23000)") ||
strings.Contains(msg, "UNIQUE constraint failed"),
"error must be the raw driver error, recognisable as a unique violation; got %q", msg)
}
// TestAgentNetworkSettings_CreateThenReadBack keeps CreateAgentNetworkSettings
// honest as an insert path: the row it writes must be fully readable, including
// the new Zone column.
func TestAgentNetworkSettings_CreateThenReadBack(t *testing.T) {
ctx := context.Background()
s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err, "real sqlite test store must come up")
defer cleanup()
want := &agentNetworkTypes.Settings{
AccountID: "acc-readback-1",
Cluster: "eu.proxy.example",
Subdomain: "swift-heron",
Zone: "gateway.example",
}
require.NoError(t, s.CreateAgentNetworkSettings(ctx, want))
got, err := s.GetAgentNetworkSettings(ctx, LockingStrengthNone, "acc-readback-1")
require.NoError(t, err, "the inserted row must be readable")
assert.Equal(t, "swift-heron", got.Subdomain)
assert.Equal(t, "gateway.example", got.Zone, "the Zone column must round-trip")
assert.Equal(t, "swift-heron.gateway.example", got.Endpoint(), "endpoint derives from zone")
}

View File

@@ -361,7 +361,9 @@ type Store interface {
GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error)
GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error)
GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error)
GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*agentNetworkTypes.Settings, error)
SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
CreateAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error
IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error
IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error
GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error)
@@ -658,6 +660,28 @@ func getMigrationsPostAuto(ctx context.Context) []migrationFunc {
func(db *gorm.DB) error {
return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db)
},
func(db *gorm.DB) error {
// Enforce globally-unique agent-network subdomains.
//
// Uniqueness used to be per-cluster and advisory (a pre-read
// "taken" set with no DB constraint). Once the endpoint hangs off a
// shared zone the label must be unique across that whole zone, and
// the allocator depends on the database rejecting duplicates so it
// can retry with a fresh label.
//
// The pre-existing idx_agent_network_settings_cluster_subdomain is
// left in place: it is non-unique and indexes subdomain alone
// (Cluster carries no tag), so it neither conflicts nor suffices.
// It must also stay for a second, load-bearing reason on mysql:
// its gorm:"index:" tag on the Subdomain field is what makes gorm
// size that column as varchar(191) instead of longtext. mysql
// cannot put a longtext column in a unique index at all, so
// dropping this "redundant" index as unneeded would silently
// break the migration above on that dialect.
return migration.CreateIndexIfNotExists[agentNetworkTypes.Settings](
ctx, db, "idx_agent_network_settings_subdomain_unique", "subdomain",
)
},
}
}

View File

@@ -268,6 +268,20 @@ func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups)
}
// CreateAgentNetworkSettings mocks base method.
func (m *MockStore) CreateAgentNetworkSettings(ctx context.Context, settings *types.Settings) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateAgentNetworkSettings", ctx, settings)
ret0, _ := ret[0].(error)
return ret0
}
// CreateAgentNetworkSettings indicates an expected call of CreateAgentNetworkSettings.
func (mr *MockStoreMockRecorder) CreateAgentNetworkSettings(ctx, settings interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkSettings), ctx, settings)
}
// CreateAgentNetworkUsage mocks base method.
func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *types.AgentNetworkUsage, groups []types.AgentNetworkUsageGroup) error {
m.ctrl.T.Helper()
@@ -1702,6 +1716,21 @@ func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStren
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster)
}
// GetAgentNetworkSettingsBySubdomain mocks base method.
func (m *MockStore) GetAgentNetworkSettingsBySubdomain(ctx context.Context, lockStrength LockingStrength, subdomain string) (*types.Settings, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAgentNetworkSettingsBySubdomain", ctx, lockStrength, subdomain)
ret0, _ := ret[0].(*types.Settings)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAgentNetworkSettingsBySubdomain indicates an expected call of GetAgentNetworkSettingsBySubdomain.
func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsBySubdomain(ctx, lockStrength, subdomain interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsBySubdomain", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsBySubdomain), ctx, lockStrength, subdomain)
}
// GetAgentNetworkUsageRows mocks base method.
func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkUsage, error) {
m.ctrl.T.Helper()

View File

@@ -254,6 +254,7 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
peerGroups := a.GetPeerGroups(peerID)
zonesByApex := map[string]*nbdns.CustomZone{}
var skippedNoZoneApex []string
for _, svc := range a.Services {
if svc == nil || !svc.Enabled || !svc.Private {
@@ -272,6 +273,15 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
serviceDomainZone := a.privateServiceDomainZone(svc)
if serviceDomainZone == "" {
// This service passed every gate above (enabled, private,
// AccessGroups, connected proxy peers) and would otherwise have
// emitted a record, but its domain matches neither its DNSZone,
// its ProxyCluster, nor any validated custom-domain row. Collected
// rather than logged here — this runs per peer x per service, and
// logging inline here would reintroduce the per-peer noise the
// "0 zones" diagnostic below deliberately avoids.
skippedNoZoneApex = append(skippedNoZoneApex,
fmt.Sprintf("%s(domain=%s cluster=%s dns_zone=%q)", svc.ID, svc.Domain, svc.ProxyCluster, svc.DNSZone))
continue
}
@@ -325,6 +335,10 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
svc.ID, svc.Domain, svc.ProxyCluster, len(proxyPeers), skippedDisconnected)
}
}
if len(skippedNoZoneApex) > 0 {
log.Debugf("private-zone synth: peer %s account %s skipped %d service(s) with no matching zone apex: %s",
peerID, a.Id, len(skippedNoZoneApex), strings.Join(skippedNoZoneApex, ", "))
}
out := make([]nbdns.CustomZone, 0, len(zonesByApex))
for _, zone := range zonesByApex {
@@ -344,8 +358,18 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
}
// privateServiceDomainZone returns the DNS zone name for the given private service domain by
// looking at the proxy cluster domain then the custom domains.
// checking its DNSZone, then the proxy cluster domain, then the custom domains.
func (a *Account) privateServiceDomainZone(svc *service.Service) string {
// Placement-free endpoints (<subdomain>.<zone>) carry their zone
// explicitly: it is server config, so it matches neither the serving
// proxy's address nor any per-account custom-domain row. Checked first so
// the apex stays the zone even once ProxyCluster becomes the tenant
// hostname itself (a private managed proxy), which would otherwise make the
// apex the full hostname and churn the client's zone set on cutover.
if svc.DNSZone != "" && domainFromSuffix(svc.Domain, svc.DNSZone) {
return svc.DNSZone
}
if domainFromSuffix(svc.Domain, svc.ProxyCluster) {
return svc.ProxyCluster
}

View File

@@ -423,6 +423,39 @@ func TestSynthesizePrivateServiceZones_MixedClusterCustomAndPublic(t *testing.T)
"only the 4 private custom services surface in the custom zone (public one excluded)")
}
// TestSynthesizePrivateServiceZones_ZoneBasedEndpoint_UsesZoneApex — a
// zone-based tenant still served by the SHARED proxy has a hostname whose
// parent is the zone, matching neither ProxyCluster nor any validated
// custom-domain row. Without DNSZone the apex resolves to "" and the service is
// skipped entirely, so the tenant's endpoint resolves to nothing.
func TestSynthesizePrivateServiceZones_ZoneBasedEndpoint_UsesZoneApex(t *testing.T) {
account := privateZoneTestAccount(t)
svc := account.Services[0]
svc.Domain = "brave-otter.gateway.netbird.ai"
svc.DNSZone = "gateway.netbird.ai"
// ProxyCluster stays the shared cluster address — the pre-private cohort.
zones := account.SynthesizePrivateServiceZones("user-peer")
require.Len(t, zones, 1, "a zone-based endpoint must still produce one zone")
assert.Equal(t, "gateway.netbird.ai.", zones[0].Domain, "apex must be the placement-free zone, not the cluster")
require.Len(t, zones[0].Records, 1)
assert.Equal(t, "brave-otter.gateway.netbird.ai.", zones[0].Records[0].Name)
assert.Equal(t, "100.64.0.99", zones[0].Records[0].RData, "still points at the serving proxy peer")
}
// TestSynthesizePrivateServiceZones_UnvalidatedDomain_StillSkipped locks the
// scope of the fix: a service matching no cluster suffix, no validated custom
// domain, AND carrying no DNSZone must keep resolving to nothing. A blanket
// "use the parent domain" fallback would hand it mesh DNS and bypass domain
// validation.
func TestSynthesizePrivateServiceZones_UnvalidatedDomain_StillSkipped(t *testing.T) {
account := privateZoneTestAccount(t)
account.Services[0].Domain = "api.unvalidated.example.com"
zones := account.SynthesizePrivateServiceZones("user-peer")
assert.Empty(t, zones, "no cluster suffix, no validated Domains row, no DNSZone → no records")
}
// recordNames returns the record names of a zone for order-independent assertions.
func recordNames(zone nbdns.CustomZone) []string {
names := make([]string, 0, len(zone.Records))

View File

@@ -68,7 +68,7 @@ type ProxyAccessTokenGenerated struct {
// CreateNewProxyAccessToken generates a new proxy access token.
// Returns the token with hashed value stored and plain token for one-time display.
func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *string, createdBy string) (*ProxyAccessTokenGenerated, error) {
hashedToken, plainToken, err := generateProxyToken()
hashedToken, plainToken, err := GenerateProxyToken()
if err != nil {
return nil, err
}
@@ -94,7 +94,10 @@ func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *
}, nil
}
func generateProxyToken() (HashedProxyToken, PlainProxyToken, error) {
// GenerateProxyToken generates a new random proxy token, returning its SHA-256
// hash (for storage) and the one-time plaintext. Exported so external modules
// can mint tokens in the canonical proxy-token format.
func GenerateProxyToken() (HashedProxyToken, PlainProxyToken, error) {
secret, err := b.Random(ProxyTokenSecretLength)
if err != nil {
return "", "", err

View File

@@ -1,6 +1,7 @@
package types
import (
"strings"
"testing"
"time"
@@ -123,6 +124,22 @@ func TestCreateNewProxyAccessToken(t *testing.T) {
})
}
func TestGenerateProxyToken(t *testing.T) {
hashed, plain, err := GenerateProxyToken()
if err != nil {
t.Fatal(err)
}
if err := plain.Validate(); err != nil {
t.Errorf("generated token failed Validate(): %v", err)
}
if plain.Hash() != hashed {
t.Error("returned hashed token does not match Hash(plain)")
}
if !strings.HasPrefix(string(plain), ProxyTokenPrefix) {
t.Errorf("token %q missing prefix %q", plain, ProxyTokenPrefix)
}
}
func TestProxyAccessToken_IsExpired(t *testing.T) {
past := time.Now().Add(-1 * time.Hour)
future := time.Now().Add(1 * time.Hour)

View File

@@ -53,7 +53,7 @@ func newChainIntegration(t *testing.T) *chainIntegrationFixture {
require.NoError(t, err)
t.Cleanup(cleanUp)
manager := agentnetwork.NewManager(st, nil, nil, nil)
manager := agentnetwork.NewManager(st, nil, nil, nil, "")
server := &mgmtgrpc.ProxyServiceServer{}
server.SetAgentNetworkLimitsService(manager)

View File

@@ -102,7 +102,7 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) {
require.NoError(t, err, "real sqlite test store must come up")
t.Cleanup(cleanup)
anMgr := agentnetwork.NewManager(st, nil, nil, nil)
anMgr := agentnetwork.NewManager(st, nil, nil, nil, "")
server := &mgmtgrpc.ProxyServiceServer{}
server.SetAgentNetworkLimitsService(anMgr)

View File

@@ -30,7 +30,23 @@ mkdir -p /usr/local/bin/
$AGENT service install || true
$AGENT service start || true
open $APP
console_user=$(stat -f%Su /dev/console 2>/dev/null)
case "$console_user" in
""|root|loginwindow|_mbsetupuser)
echo "No active GUI user session (console user: '${console_user:-none}'); skipping UI launch."
;;
*)
uid=$(id -u "$console_user" 2>/dev/null)
if [ -z "$uid" ]; then
echo "Could not resolve uid for console user '$console_user'; skipping UI launch."
else
echo "Launching NetBird UI as console user $console_user (uid $uid)."
if ! launchctl asuser "$uid" sudo -u "$console_user" -H open "$APP"; then
echo "Failed to launch NetBird UI; if autostart is enabled it will start at next login."
fi
fi
;;
esac
echo "Finished Netbird installation successfully"
exit 0 # all good

View File

@@ -228,15 +228,17 @@ func DecodeEnvelope(env *proto.NetworkMapEnvelope) (*types.NetworkMapComponents,
return c, nil
}
// decodeAccountNetwork never returns nil — Calculate() dereferences
// c.Network unconditionally, and servers that predate the fix omit the field
// entirely from the empty-components envelope.
func decodeAccountNetwork(an *proto.AccountNetwork) *types.Network {
n := &types.Network{}
if an == nil {
return nil
}
n := &types.Network{
Identifier: an.Identifier,
Dns: an.Dns,
Serial: an.Serial,
return n
}
n.Identifier = an.Identifier
n.Dns = an.Dns
n.Serial = an.Serial
if an.NetCidr != "" {
if _, ipnet, err := net.ParseCIDR(an.NetCidr); err == nil && ipnet != nil {
n.Net = *ipnet

View File

@@ -221,6 +221,66 @@ func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) {
"client-side Calculate must connect the same remote peers as the server")
}
// TestEnvelopeToNetworkMap_EmptyComponents covers the graceful-degrade path
// the server takes for a peer that is missing from the account or absent from
// the validated-peers map. The legacy server short-circuited before
// Calculate() and shipped a NetworkMap carrying only the account Network; the
// components path runs Calculate() on the client instead, so the envelope must
// carry Network or the client panics dereferencing a nil *types.Network.
func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) {
localPeerKey := randomWgKey(t)
c := types.EmptyNetworkMapComponents(&types.NetworkMapComponents{
PeerID: "peer-A",
Network: &types.Network{
Identifier: "net-empty",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 7,
},
Peers: map[string]*types.ComponentPeer{
"peer-A": {ID: "peer-A", Key: localPeerKey, IP: netip.AddrFrom4([4]byte{100, 64, 0, 1})},
},
})
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
Components: c,
DNSDomain: "netbird.cloud",
})
require.NotNil(t, envelope.GetFull().Network, "empty envelope must carry the account Network")
wire, err := goproto.Marshal(envelope)
require.NoError(t, err, "marshal envelope")
var decoded proto.NetworkMapEnvelope
require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope")
result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud")
require.NoError(t, err, "EnvelopeToNetworkMap must degrade gracefully on empty components")
require.Equal(t, uint64(7), result.NetworkMap.Serial)
require.Empty(t, result.NetworkMap.RemotePeers, "unvalidated peer connects to nobody")
}
// TestEnvelopeToNetworkMap_MissingNetwork simulates a server that omits
// AccountNetwork from the envelope. Clients must degrade rather than panic, so
// they survive talking to a management server that predates the encoder fix.
func TestEnvelopeToNetworkMap_MissingNetwork(t *testing.T) {
c, localPeerKey := buildSmokeComponents(t)
envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{
Components: c,
DNSDomain: "netbird.cloud",
})
envelope.GetFull().Network = nil
wire, err := goproto.Marshal(envelope)
require.NoError(t, err, "marshal envelope")
var decoded proto.NetworkMapEnvelope
require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope")
result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud")
require.NoError(t, err, "a missing AccountNetwork must not panic the client")
require.NotNil(t, result.Components.Network)
require.NotEmpty(t, result.NetworkMap.RemotePeers, "the rest of the snapshot stays usable")
}
// buildSmokeComponents returns a minimal NetworkMapComponents (2 peers, 1
// group, 1 allow policy) plus the receiving peer's WG public key. Sufficient
// to validate the encode → marshal → decode → Calculate pipeline produces