Compare commits

..

127 Commits

Author SHA1 Message Date
pascal
28074405e1 change wording from failer -> failing to pass codespell 2026-08-06 16:02:02 +02:00
pascal
fd23461b03 add network map compute test 2026-08-06 15:57:43 +02:00
Dmitri Dolguikh
b19cf7405d Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-06 15:18:13 +02:00
Dmitri Dolguikh
90e0c5bd0c added GetPolicies test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-06 15:17:39 +02:00
pascal
6ccb2bb239 handle nil values in policy, nameserver and resources 2026-08-06 15:12:12 +02:00
Dmitri Dolguikh
b160cb43b1 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-06 13:03:28 +02:00
Dmitri Dolguikh
1902777509 added a test for GetPeers
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-06 13:03:00 +02:00
pascal
cff46eee67 introduce peerGroupsIdx to avoid looping through the groups to figure out peer membership 2026-08-06 12:49:36 +02:00
pascal
0513109c35 improve looping on connected peers filtering 2026-08-06 12:31:26 +02:00
Dmitri Dolguikh
6b8393c6b0 add GetAppliedZoneCandidatesViaPgxConnection test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 18:43:20 +02:00
Dmitri Dolguikh
316e82337f cleanup query execution in tests
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 18:09:28 +02:00
Dmitri Dolguikh
bacacb8f77 deleted group_test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 16:45:25 +02:00
Dmitri Dolguikh
994e84a686 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types 2026-08-05 16:35:08 +02:00
Dmitri Dolguikh
07809fe923 added test for GetAllowedUsersViaPgxConnection
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 16:34:44 +02:00
pascal
ec3e37456e use Exec instead of Query for inserts and assert every insert error 2026-08-05 16:33:48 +02:00
pascal
c81103dfa6 fix linter comments 2026-08-05 16:21:01 +02:00
Dmitri Dolguikh
ba574dc739 added tests for GetPrivateServicesViaPgxConnection and GetPrivateServicesViaPgxConnection
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 16:06:43 +02:00
Dmitri Dolguikh
067982f772 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 15:26:38 +02:00
Dmitri Dolguikh
ce4f0a7821 added GetAccountSettingsViaPgxConnection test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 15:26:06 +02:00
pascal
3218e2f744 fix client tests 2026-08-05 15:18:19 +02:00
pascal
7e24c27ef1 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types 2026-08-05 15:09:41 +02:00
pascal
06817271bf fix embedded client test 2026-08-05 15:09:29 +02:00
Dmitri Dolguikh
1530739b54 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 14:58:36 +02:00
Dmitri Dolguikh
e745aedc92 added GetRoutesViaPgxConnection test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 14:58:09 +02:00
pascal
4cf3903c83 fix management <-> shared dependencies 2026-08-05 14:48:27 +02:00
pascal
1e14b554a6 fix management <-> shared dependencies 2026-08-05 14:48:18 +02:00
Dmitri Dolguikh
11a3627899 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 14:29:43 +02:00
Dmitri Dolguikh
2442bd6198 tests for GetNameServerGroupsViaPgxConnection and GetPostureChecksViaPgxConnection
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 14:29:07 +02:00
pascal
006cee000f Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types 2026-08-05 13:56:46 +02:00
pascal
c93aa03c0e merge main 2026-08-05 13:56:35 +02:00
Dmitri Dolguikh
efe2eaeb09 added GetDomains test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 12:37:58 +02:00
Dmitri Dolguikh
1926e983fb added GetDnsSettings test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 12:03:36 +02:00
Dmitri Dolguikh
e9b8175915 added GetNetworks test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 11:53:40 +02:00
Dmitri Dolguikh
88f1930450 added account network test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 11:40:23 +02:00
Dmitri Dolguikh
1774a3d9dd add network resource query test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 10:52:39 +02:00
Dmitri Dolguikh
795e06ce83 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types 2026-08-05 10:16:54 +02:00
Dmitri Dolguikh
748f6b3fbb fixes + tests
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-05 10:16:21 +02:00
pascal
d5adfc799f handle policy with no rules to avoid index panic 2026-08-04 20:57:57 +02:00
pascal
60fe56e442 fix assert equal ordering 2026-08-04 20:56:16 +02:00
pascal
327fa4c0d6 fix group clone 2026-08-04 20:52:16 +02:00
pascal
ed015a7972 skip resource from proto failure 2026-08-04 20:46:10 +02:00
pascal
b43708e31b silently skip resource to proto failure 2026-08-04 20:42:43 +02:00
pascal
ce55b8406b handle not found peer on posture vali 2026-08-04 20:34:30 +02:00
pascal
0456b64ea1 silent skip on non existing groups references 2026-08-04 20:28:06 +02:00
Maycon Santos
6526fc2bec [management] Prevent deleting groups referenced by reverse proxy services (#7062)
## Describe your changes

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

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

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

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

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

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


## Issue ticket number and link

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

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

## Stack

<!-- branch-stack -->

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

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

## Documentation
Select exactly one:

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

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

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


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

## Chores

* Updated underlying application components to support compatibility and
ongoing maintenance.
* Clarified Linux tray interaction documentation, including
platform-specific left- and right-click behavior and menu activation.
* No user-facing features, workflow changes, or visual updates are
included.
* Existing Linux tray behavior remains unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 17:36:04 +02:00
Dmitri Dolguikh
dbfdd04c7b adding network-routers integration query test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 16:43:17 +02:00
Zoltan Papp
f2d13b884a [client] Fix session expired relogin (#7055)
## Describe your changes

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

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

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


## Issue ticket number and link

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

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

## Stack

<!-- branch-stack -->

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

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

## Documentation
Select exactly one:

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

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

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


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

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

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


## Issue ticket number and link

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

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

## Stack

<!-- branch-stack -->

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

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

## Documentation
Select exactly one:

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

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

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


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

## Summary by CodeRabbit

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

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 16:02:09 +02:00
Dmitri Dolguikh
4d010f60ce fix another import
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 15:40:28 +02:00
Zoltan Papp
78c1c2fc32 [client] Probe the daemon login with IsLoginRequired (#7052)
## Describe your changes

Probe the daemon login with IsLoginRequired 

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

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

## Issue ticket number and link

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

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

## Stack

<!-- branch-stack -->

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

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

## Documentation
Select exactly one:

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

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

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


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

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved login handling when Management connectivity checks fail.
* Prevented unnecessary SSO prompts for already-authenticated sessions.
* Preserved setup-key login behavior while ensuring authentication
attempts proceed correctly.
* Login failures now return a clear failure status when authentication
state cannot be verified.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-04 14:27:47 +02:00
Dmitri Dolguikh
3272058e56 fix extra setting manager package
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 13:34:25 +02:00
Dmitri Dolguikh
70c3feb05b Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 13:04:51 +02:00
Dmitri Dolguikh
a0fe80cd99 wire up validated peers
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-04 12:52:09 +02:00
Maycon Santos
bc7a15ab71 [management] Align agent-network API contracts for API clients (#7026)
## Describe your changes

Work on the Terraform provider (terraform-provider-netbird #177–#183)
surfaced places where the agent-network API broke its own contracts or
deviated from the conventions the rest of the management API follows,
forcing client-side workarounds.

Settings reads now follow the settings-endpoint convention: GET always
answers with a JSON object. Before bootstrap it returns the defaults
with an empty cluster/subdomain/endpoint (previously 200 with a JSON
`null` body, while the spec said 404). The settings PUT can bootstrap
the account by carrying a `cluster` — previously the row could only come
into existence through the first provider create, and a settings-first
setup was impossible; a differing cluster on a bootstrapped account is
rejected instead of silently ignored. PUT remains full-state.

The provider PUT schema promised omit-preserves semantics for several
operator-editable fields that the handler never delivered (it builds the
row from the request, like every other update handler). The schema
wording now matches the shipped full-state behavior; only the api_key
(secret) and session keys stay preserved by the manager. Identity
headers are always present in provider responses so an explicitly
cleared value round-trips as an empty string.

The Go REST client gains the full agent-network surface (catalog,
providers, policies, guardrails, budget rules, settings), including a
shim translating the legacy 200+`null` settings body from older servers
into an `IsNotFound` error.

Note for reviewers: the dashboard special-cased the `null` settings
body; it needs a small follow-up for the new defaults response (in
progress).
2026-08-04 01:59:09 +02:00
pascal
6fadf8f24a fix nmdata store expanding router peer groups alongside static peer 2026-08-03 23:58:47 +02:00
pascal
ef0032685b fix nmdata store shipping zones for disabled/public services 2026-08-03 23:54:22 +02:00
Zoltan Papp
530021aec6 [client] Update wails to v3.0.0-beta.3 (#7038)
## Describe your changes

Update wails to v3.0.0-beta.3

## Issue ticket number and link

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

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

## Stack

<!-- branch-stack -->

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

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

## Documentation
Select exactly one:

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

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

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


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

## Summary by CodeRabbit

* **Chores**
* Updated the application framework dependency to a newer beta release
for improved compatibility and stability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 23:19:59 +02:00
pascal
5ed38569f7 fix PrivateServiceCandidates nil assumption 2026-08-03 23:19:55 +02:00
pascal
d4e1c8978e add all group to user group lookup 2026-08-03 23:08:43 +02:00
Zoltan Papp
1bedb4e59d [client, android] Reuse the profile's account for Android SSO logins (#6988)
The Android binding never recorded which account a profile belongs to,
so every interactive login and every session extend went to the IdP with
no login_hint. With nothing to go on the IdP picks an account itself,
which on a session extend means re-authenticating an account the profile
is already signed in with.

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

The email is keyed off the profile's config path rather than the active
profile: Auth.login runs in a goroutine, so the active profile can
change under a flow already in flight. It lands in
<profile>.account.json, not the <profile>.state.json desktop uses for
the same data — there the email and the engine's state manager sit in
different directories, but on Android both resolve under files/, and the
state manager rewrites the whole file from its own keys.

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


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

* **New Features**
* Added account email and active-status details to Android profile
information.
* Improved SSO sign-in and session renewal by restoring the previously
used account as a login hint.
* Added Android-specific profile email persistence with automatic
cleanup on logout.

* **Bug Fixes**
* Profile email persistence failures now generate warnings without
blocking login or logout.
* Improved handling of missing or unreadable account data and repeated
logout cleanup.

* **Tests**
* Added coverage for account-file naming, email persistence, and logout
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 22:50:57 +02:00
pascal
d5ac70d806 fix missing ForceRoutingPeerDNSResolution on the nmdata store path 2026-08-03 22:50:06 +02:00
pascal
23579e4dd4 Skip the left-join NULL row instead of failing the account load + drop unsupported record types silently to match buildAppliedZoneCandidates 2026-08-03 22:38:12 +02:00
pascal
a00c5164a8 continue on resource policy loop + exclude disabled policies form index 2026-08-03 22:29:47 +02:00
pascal
02138dffdd fix group id mapping 2026-08-03 22:24:28 +02:00
pascal
02ec1f5dcb fix equivalence test 2026-08-03 22:24:11 +02:00
pascal
07d0440e34 fix slice initialization to avoid nil pointer dereference 2026-08-03 21:51:48 +02:00
pascal
2c85d94c6c hookup validated peers 2026-08-03 21:47:41 +02:00
camiloariza
7546e7751c [client, android] Reuse the persisted configuration when enrolling (#7022)
## Describe your changes

NewAuth builds a fresh in-memory configuration on every call, which
means a new WireGuard key each time. The peer registers under that key
and the key is written out, so any peer registered by an earlier call is
orphaned on the server — a client that enrols twice leaves two entries
and owns neither.

It also breaks the enrol-then-run sequence. `RunWithoutLogin` reloads
the configuration from disk through `UpdateOrCreateConfig`, so the
identity that registered is not necessarily the identity that runs, and
the management stream rejects it:

```
failed to login to Management Service: rpc error: code = PermissionDenied
desc = no peer auth method provided, please use a setup key or interactive SSO login
```

followed by a panic in `ConnectClient.run`.

### How it was found

Embedding the Android client in an application that enrols with a setup
key and then runs. Eight orphaned peers accumulated on a self-hosted
management server before the cause was clear, because every restart
registered a new one.

### The change

`NewAuth` passes `ConfigPath` and uses `UpdateOrCreateConfig`, so an
existing configuration is reused and one is only created when absent. A
caller wanting a fresh identity can delete the file — which is what
"forget this account" already does.

### Test

`TestNewAuth_ReusesPersistedIdentity` fails on the current code:

```
--- FAIL: TestNewAuth_ReusesPersistedIdentity (0.00s)
    login_test.go:33: private key changed between calls: a second enrolment would orphan the peer registered by the first
```

and passes with the fix. `TestNewAuth_CreatesConfigWhenAbsent` covers
the first-enrolment path being unchanged. Both run in `client/android`
on Linux.

Per CONTRIBUTING, opening directly as a bug fix rather than raising an
issue first.

## Issue ticket number and link


[NET-1465](https://linear.app/netbird/issue/NET-1465/agent-network-rest-api-settings-defaults-bootstrap-via-put-provider)

## Stack

<!-- branch-stack -->

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

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

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why): the
API reference is generated from the OpenAPI spec, which this PR updates
in-repo.

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

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
2026-08-03 18:42:15 +02:00
Zoltan Papp
075b319fb3 [client, android] Pull fresh TUN settings on Android rebuild (#6991)
## Describe your changes

Pull fresh TUN settings on Android rebuild instead of push

The Android TUN rebuild consumed state pushed through notifications and
a Java-side snapshot, and both sources were unreliable. The DNS
search-domain notifier fired OnNetworkChanged with an empty string,
which the rebuild handler treated as the new route list, so any search
domain change rebuilt the TUN with zero routes and cut all tunnel
traffic. The rebuild also reused the search domains cached at the last
establish, so search domain updates never reached the TUN at runtime.

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

Requires the matching android-client change that switches recreateTUN to
the pull API.

## 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**
  - Added access to current TUN route ranges and DNS search domains.
- TUN settings are returned in a mobile-friendly format for easier
integration.

- **Improvements**
  - Route changes are detected and synchronized more reliably.
- Current routing information now reflects active routes, including
supported fake-IP ranges.
- Simplified network initialization for more consistent startup
behavior.

- **API Changes**
- Removed the obsolete network-map retrieval method from the management
client interface.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 18:23:02 +02:00
Dmitri Dolguikh
1f190a50cf Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 18:22:34 +02:00
Dmitri Dolguikh
52fdfd5bdc add an posture-check-id to public-id index
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 18:21:07 +02:00
Dmitri Dolguikh
e35a0f3318 added PrivateServiceCandidates
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 18:04:11 +02:00
Zoltan Papp
b82a42c855 [misc] Add android and ios tags to PR title check (#7037)
## Describe your changes
Extend CI tag list with Android and iOS


## Issue ticket number and link

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

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

## Stack

<!-- branch-stack -->

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

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

## Documentation
Select exactly one:

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

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

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


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

## Summary by CodeRabbit

* **Chores**
* Updated pull request title validation to accept `android` and `ios`
tags.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 17:58:29 +02:00
pascal
56c411babd fixed networks query and extended error wrapping 2026-08-03 17:52:33 +02:00
Dmitri Dolguikh
164b2baa4d Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 17:29:42 +02:00
Dmitri Dolguikh
0b29c6ed1a adding buildPrivateServiceCandidates
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 17:29:09 +02:00
pascal
724b61440b use new path on initial sync 2026-08-03 17:27:58 +02:00
pascal
22590ad66e hookup network map store 2026-08-03 17:27:24 +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
Dmitri Dolguikh
b8e004ea89 also build proxy-cluster to peer idx
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 14:58:53 +02:00
Dmitri Dolguikh
e2797360f4 support for applied zone candidates
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 14:32:54 +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
Dmitri Dolguikh
2f399f1e6e wire up dnssettings
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 14:12:27 +02:00
Dmitri Dolguikh
82c1e18264 renamed allowed_user_ids.go to user.got 2026-08-03 13:54:22 +02:00
Dmitri Dolguikh
ce9023bd27 wire up accountsettings
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 13:51:25 +02:00
Dmitri Dolguikh
7d33356776 support for group to user ids
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 13:47:14 +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
Dmitri Dolguikh
993291149c Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 12:20:18 +02:00
Dmitri Dolguikh
98f7ea40a1 added allowed_user_ids call
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 12:19:50 +02:00
pascal
76db9ab94f update integrated validator 2026-08-03 12:11:35 +02:00
Dmitri Dolguikh
9653b15d78 automatically fill empty public ids for view types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 11:34:22 +02:00
Dmitri Dolguikh
206bb1676b build resourcePolicies map
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-03 10:53:54 +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
Dmitri Dolguikh
600b0c752b also return a net resource to groups index from GetGroups call
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-01 11:56:17 +02:00
Dmitri Dolguikh
b02736adc3 cleanup network routers retrieval
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-08-01 11:54:14 +02:00
Dmitri Dolguikh
5d6117d2c0 adding network_map_data: compute Routers field
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-31 17:56:52 +02:00
Dmitri Dolguikh
c2f8360b00 adding support for bulding of network_map_data
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-31 15:58:40 +02:00
Dmitri Dolguikh
ea1b4d56e8 added support for queries via pgx connection
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-31 15:03:15 +02:00
Dmitri Dolguikh
acbe22b831 small cleanups
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-31 13:41:34 +02:00
Dmitri Dolguikh
3b5c8e2298 started moving pg-specific nmap tests to integration_tests/management/network_map_db/pgsql
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-31 12:29:35 +02:00
Dmitri Dolguikh
2baeb4bc0d clean up sql types conversion test
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 19:35:11 +02:00
Dmitri Dolguikh
4bf75fdc97 support for posture-checks
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 18:00:58 +02:00
Dmitri Dolguikh
799f3a3c62 Merge remote-tracking branch 'origin/revert/component-types' into revert/component-types
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 16:03:46 +02:00
Dmitri Dolguikh
129736ad61 support for account settings
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 16:03:19 +02:00
pascal
f7be9c4347 legacy network mal and equivalence test 2026-07-30 15:45:15 +02:00
Dmitri Dolguikh
e05cb5264d support for dns custom zones
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 15:34:09 +02:00
Dmitri Dolguikh
5a10561ca1 fix handling of string slices
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-30 11:31:53 +02:00
Dmitri Dolguikh
42ce83a8f3 added support for account network
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-29 18:57:08 +02:00
Dmitri Dolguikh
e620c86cd4 added support for networkrouters
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-29 18:32:51 +02:00
Dmitri Dolguikh
9dee2d60b9 added support for networkresources
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-29 18:19:34 +02:00
Dmitri Dolguikh
4525014632 support for nameservergroups
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-29 17:59:44 +02:00
Dmitri Dolguikh
23a5c0de4b added support for routes
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-29 17:37:00 +02:00
Dmitri Dolguikh
25e882004f added retrieval of policies
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-28 18:06:28 +02:00
Dmitri Dolguikh
15003258d2 networkmap read-only interface for pgsql
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-28 16:46:45 +02:00
Dmitri Dolguikh
2af3a5fba5 do not send resource policies map over the wire
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-27 19:00:30 +02:00
pascal
a6603a2e0a have explicit network map data type and do calculation from there 2026-07-27 15:17:30 +02:00
pascal
7ed3737cda revert component types 2026-07-24 15:16:55 +02:00
253 changed files with 17269 additions and 5654 deletions

View File

@@ -16,6 +16,8 @@ jobs:
const allowedTags = [
'management',
'client',
'android',
'ios',
'signal',
'proxy',
'relay',

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

@@ -57,6 +57,12 @@ type DnsReadyListener interface {
dns.ReadyListener
}
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
type TunSettings struct {
Routes string
SearchDomains string
}
func init() {
formatter.SetLogcatFormatter(log.StandardLogger())
}
@@ -76,6 +82,8 @@ type Client struct {
connectClient *internal.ConnectClient
config *profilemanager.Config
cacheDir string
// Identifies the running profile for the SSO login hint; see profile_state.go.
cfgPath string
stateChangeMu sync.Mutex
stateChangeSubID string
@@ -96,11 +104,12 @@ type Client struct {
extendCancel context.CancelFunc
}
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) {
c.stateMu.Lock()
defer c.stateMu.Unlock()
c.config = cfg
c.cacheDir = cacheDir
c.cfgPath = cfgPath
c.connectClient = cc
}
@@ -110,6 +119,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn
return c.config, c.cacheDir, c.connectClient
}
// authSnapshot returns the config together with the path it was loaded from, in
// one lock: the path identifies the profile whose account email backs the login
// hint, so reading it separately could pair one profile's config with another's
// hint when a profile switch lands in between.
func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
c.stateMu.RLock()
defer c.stateMu.RUnlock()
return c.config, c.cfgPath, c.connectClient
}
func (c *Client) getConnectClient() *internal.ConnectClient {
c.stateMu.RLock()
defer c.stateMu.RUnlock()
@@ -162,7 +181,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
defer c.ctxCancel()
c.ctxCancelLock.Unlock()
auth := NewAuthWithConfig(ctx, cfg)
auth := NewAuthWithConfig(ctx, cfg, cfgFile)
err = auth.login(urlOpener, isAndroidTV)
if err != nil {
return err
@@ -170,7 +189,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
c.setState(cfg, cacheDir, connectClient)
c.setState(cfg, cacheDir, cfgFile, connectClient)
// This path runs the interactive SSO flow, so reaching here means the peer
// is authenticated again — release the latch Status() reports from. Clear
// only once the fresh connect client is installed: until then Status()
@@ -211,7 +230,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
c.setState(cfg, cacheDir, connectClient)
c.setState(cfg, cacheDir, cfgFile, connectClient)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -240,6 +259,24 @@ func (c *Client) RenewTun(fd int) error {
return e.RenewTun(fd)
}
func (c *Client) GetTunSettings() (*TunSettings, error) {
cc := c.getConnectClient()
if cc == nil {
return nil, fmt.Errorf("engine not running")
}
e := cc.Engine()
if e == nil {
return nil, fmt.Errorf("engine not initialized")
}
routes, searchDomains := e.TunSettings()
return &TunSettings{
Routes: strings.Join(routes, ";"),
SearchDomains: strings.Join(searchDomains, ";"),
}, nil
}
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
// It works both with and without a running engine.
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {

View File

@@ -4,6 +4,8 @@ import (
"context"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/system"
@@ -36,12 +38,20 @@ type Auth struct {
}
// NewAuth instantiate Auth struct and validate the management URL
//
// The configuration at cfgPath is reused when one is already there, and only created when it is
// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on
// every call: the peer registers under that key, the key is written out, and any peer registered by
// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from
// the persisted config, because the identity it registered is not the one it runs with — the
// management stream rejects it with "no peer auth method provided".
func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
inputCfg := profilemanager.ConfigInput{
ConfigPath: cfgPath,
ManagementURL: mgmURL,
}
cfg, err := profilemanager.CreateInMemoryConfig(inputCfg)
cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg)
if err != nil {
return nil, err
}
@@ -53,11 +63,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
}, nil
}
// NewAuthWithConfig instantiate Auth based on existing config
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the
// file the config was loaded from; it identifies the profile whose account email
// backs the login_hint.
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth {
return &Auth{
ctx: ctx,
config: config,
ctx: ctx,
config: config,
cfgPath: cfgPath,
}
}
@@ -150,12 +163,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
}
jwtToken := ""
email := ""
if needsLogin {
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
jwtToken = tokenInfo.GetTokenToUse()
email = tokenInfo.Email
}
err, _ = authClient.Login(a.ctx, "", jwtToken)
@@ -163,17 +178,42 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
return fmt.Errorf("login failed: %v", err)
}
// Stored after Login, not before: a rejected token must not leave a hint
// pointing at an account that cannot be used.
if email != "" && a.cfgPath != "" {
if err := writeProfileEmail(a.cfgPath, email); err != nil {
log.Warnf("failed to store profile account email: %v", err)
}
}
go urlOpener.OnLoginSuccess()
return nil
}
// loginHintSetter is implemented by both concrete flows (PKCE and device code)
// but absent from the OAuthFlow interface, hence the assertion below — the same
// way internal/auth wires it in authenticateWithPKCEFlow.
type loginHintSetter interface {
SetLoginHint(hint string)
}
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
// leaves the choice to the IdP, which is how accounts get switched.
if a.cfgPath != "" {
if hint := readProfileEmail(a.cfgPath); hint != "" {
if setter, ok := oAuthFlow.(loginHintSetter); ok {
setter.SetLoginHint(hint)
}
}
}
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
if err != nil {
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)

View File

@@ -0,0 +1,51 @@
package android
import (
"path/filepath"
"testing"
)
// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one.
//
// Creating a new in-memory config on every call gives the client a new WireGuard private key each
// time. The peer registers under that key and the key is written out, so a peer registered by an
// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns
// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so
// the identity that registered is not the identity that runs, and the management stream rejects it
// with "no peer auth method provided, please use a setup key or interactive SSO login".
func TestNewAuth_ReusesPersistedIdentity(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.json")
first, err := NewAuth(cfgPath, "https://api.example.com:443")
if err != nil {
t.Fatalf("first NewAuth: %v", err)
}
if first.config.PrivateKey == "" {
t.Fatal("first NewAuth produced no private key")
}
second, err := NewAuth(cfgPath, "https://api.example.com:443")
if err != nil {
t.Fatalf("second NewAuth: %v", err)
}
if second.config.PrivateKey != first.config.PrivateKey {
t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first")
}
}
// A missing configuration is still created, so a first enrolment works unchanged.
func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) {
cfgPath := filepath.Join(t.TempDir(), "config.json")
auth, err := NewAuth(cfgPath, "https://api.example.com:443")
if err != nil {
t.Fatalf("NewAuth: %v", err)
}
if auth.config == nil || auth.config.PrivateKey == "" {
t.Fatal("NewAuth did not create a usable configuration")
}
if auth.cfgPath != cfgPath {
t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath)
}
}

View File

@@ -13,18 +13,17 @@ import (
)
const (
// Android-specific config filename (different from desktop default.json)
defaultConfigFilename = "netbird.cfg"
// Subdirectory for non-default profiles (must match Java Preferences.java)
profilesSubdir = "profiles"
// Android uses a single user context per app (non-empty username required by ServiceManager)
androidUsername = "android"
)
// Profile represents a profile for gomobile
type Profile struct {
ID string
Name string
ID string
Name string
// Email is the account this profile last logged in with, "" if it never
// completed an SSO login or was logged out. See profile_state.go.
Email string
IsActive bool
}
@@ -101,6 +100,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
profiles = append(profiles, &Profile{
ID: p.ID.String(),
Name: p.Name,
Email: pm.profileEmail(p.ID.String()),
IsActive: p.IsActive,
})
}
@@ -123,7 +123,22 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
if err != nil {
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
}
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
return &Profile{
ID: prof.ID.String(),
Name: prof.Name,
Email: pm.profileEmail(prof.ID.String()),
IsActive: true,
}, nil
}
// profileEmail returns the account email recorded for a profile. Display-only, so
// an unresolvable path degrades to "" rather than an error.
func (pm *ProfileManager) profileEmail(id string) string {
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return ""
}
return readProfileEmail(configPath)
}
// SwitchProfile switches to a different profile
@@ -185,6 +200,11 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
return fmt.Errorf("failed to save config: %w", err)
}
// Not fatal: a stale hint costs an account switch, not the logout itself.
if err := removeProfileEmail(configPath); err != nil {
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
}
log.Infof("logged out from profile: %s", id)
return nil
}

View File

@@ -0,0 +1,108 @@
package android
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/util"
)
const (
// Android-specific config filename (different from desktop default.json)
defaultConfigFilename = "netbird.cfg"
// Subdirectory for non-default profiles (must match Java Preferences.java)
profilesSubdir = "profiles"
// profileAccountSuffix names the file holding the profile's account email.
// Deliberately not ".state.json", which desktop uses for the same data:
// there the email and the engine's state manager live in different
// directories, but on Android both resolve under files/, so sharing the name
// would have the two overwrite each other — the state manager rewrites the
// whole file from its own keys (see statemanager.Manager.PersistState), and
// this package's writer does the same in reverse.
profileAccountSuffix = ".account.json"
)
// profileAccountPathFor derives the account file path from a profile's config
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
//
// Deriving from the config path rather than resolving the active profile keeps
// the write on the profile the login actually ran for: Auth.login runs in a
// goroutine, so the active profile can change under a flow already in flight.
func profileAccountPathFor(configPath string) (string, error) {
if configPath == "" {
return "", fmt.Errorf("empty config path")
}
base := filepath.Base(configPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
if stem == "" || stem == "." {
return "", fmt.Errorf("config path %q has no filename stem", configPath)
}
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
}
// readProfileEmail returns the account email stored for the profile whose config
// lives at configPath. A missing or unreadable file yields "", which leaves the
// account choice to the IdP.
func readProfileEmail(configPath string) string {
accountPath, err := profileAccountPathFor(configPath)
if err != nil {
log.Debugf("no profile account path for login hint: %v", err)
return ""
}
var state profilemanager.ProfileState
if _, err := util.ReadJson(accountPath, &state); err != nil {
if !os.IsNotExist(err) {
log.Debugf("failed to read profile account for login hint: %v", err)
}
return ""
}
return state.Email
}
// writeProfileEmail records the account email for the profile whose config lives
// at configPath, so later logins can pass it as an OIDC login_hint. An empty
// email is ignored rather than blanking what is already stored.
func writeProfileEmail(configPath string, email string) error {
if email == "" {
return nil
}
accountPath, err := profileAccountPathFor(configPath)
if err != nil {
return fmt.Errorf("resolve profile account path: %w", err)
}
state := profilemanager.ProfileState{Email: email}
if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
return fmt.Errorf("write profile account: %w", err)
}
return nil
}
// removeProfileEmail drops the stored account email. Called on logout: while the
// email is on disk it goes out as a login_hint, which would steer the next login
// straight back into the account just logged out of. Mirrors the desktop UI's
// RemoveProfileState call.
func removeProfileEmail(configPath string) error {
accountPath, err := profileAccountPathFor(configPath)
if err != nil {
return fmt.Errorf("resolve profile account path: %w", err)
}
if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove profile account: %w", err)
}
return nil
}

View File

@@ -0,0 +1,161 @@
package android
import (
"os"
"path/filepath"
"testing"
)
func TestProfileAccountPathFor(t *testing.T) {
tests := []struct {
name string
configPath string
want string
wantErr bool
}{
{
name: "default profile",
configPath: "/data/data/io.netbird.client/files/netbird.cfg",
want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"),
},
{
name: "id profile",
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
},
{
name: "legacy name-keyed profile is handled the same way",
configPath: "/data/data/io.netbird.client/files/profiles/work.json",
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"),
},
{
name: "empty path is rejected",
configPath: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := profileAccountPathFor(tt.configPath)
if tt.wantErr {
if err == nil {
t.Fatalf("expected an error, got path %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
root := "/data/data/io.netbird.client/files"
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
if err != nil {
t.Fatalf("default profile: %v", err)
}
idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
if err != nil {
t.Fatalf("id profile: %v", err)
}
if defaultAccount == idAccount {
t.Fatalf("default and id profile share an account file: %q", defaultAccount)
}
}
// The account file must never land on the engine state file: on Android both
// resolve under files/, and the state manager rewrites the whole file from its
// own keys, so sharing a path would have the two overwrite each other. The
// expected names here mirror ProfileManager.GetStateFilePath.
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
root := "/data/data/io.netbird.client/files"
cases := []struct {
configPath string
engineState string
}{
{
configPath: filepath.Join(root, defaultConfigFilename),
engineState: filepath.Join(root, "state.json"),
},
{
configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
},
}
for _, c := range cases {
account, err := profileAccountPathFor(c.configPath)
if err != nil {
t.Fatalf("%s: %v", c.configPath, err)
}
if account == c.engineState {
t.Errorf("account file collides with the engine state file: %q", account)
}
}
}
func TestWriteThenReadProfileEmail(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
if err := ensureDirFor(t, configPath); err != nil {
t.Fatalf("prepare dir: %v", err)
}
if got := readProfileEmail(configPath); got != "" {
t.Errorf("expected no email before a login, got %q", got)
}
const email = "user@example.com"
if err := writeProfileEmail(configPath, email); err != nil {
t.Fatalf("write: %v", err)
}
if got := readProfileEmail(configPath); got != email {
t.Errorf("got %q, want %q", got, email)
}
if err := removeProfileEmail(configPath); err != nil {
t.Fatalf("remove: %v", err)
}
if got := readProfileEmail(configPath); got != "" {
t.Errorf("expected no email after logout, got %q", got)
}
// Logout may run on a never-logged-in profile, so a second remove must pass.
if err := removeProfileEmail(configPath); err != nil {
t.Fatalf("second remove should be a no-op: %v", err)
}
}
func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
if err := ensureDirFor(t, configPath); err != nil {
t.Fatalf("prepare dir: %v", err)
}
const email = "user@example.com"
if err := writeProfileEmail(configPath, email); err != nil {
t.Fatalf("write: %v", err)
}
if err := writeProfileEmail(configPath, ""); err != nil {
t.Fatalf("write empty: %v", err)
}
if got := readProfileEmail(configPath); got != email {
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
}
}
func ensureDirFor(t *testing.T, path string) error {
t.Helper()
return os.MkdirAll(filepath.Dir(path), 0o700)
}

View File

@@ -278,7 +278,7 @@ func (c *Client) endExtend() {
}
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
cfg, _, cc := c.stateSnapshot()
cfg, cfgPath, cc := c.authSnapshot()
if cfg == nil || cc == nil {
return fmt.Errorf("engine is not running")
}
@@ -293,7 +293,10 @@ func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isA
}
defer authClient.Close()
a := &Auth{ctx: ctx, config: cfg}
// Passing the config path makes the flow pick up the login_hint: an extend
// renews the session of the account already signed in, so it must not stop to
// offer a choice.
a := NewAuthWithConfig(ctx, cfg, cfgPath)
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)

View File

@@ -27,8 +27,8 @@ import (
const errCloseConnection = "Failed to close connection: %v"
var (
logFileCount uint32
systemInfoFlag bool
logFileCount uint32
systemInfoFlag bool
uploadBundleFlag bool
uploadBundleURLFlag string
uploadBundleInsecureFlag bool

View File

@@ -124,7 +124,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil)
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil {

View File

@@ -21,8 +21,8 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"

View File

@@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string {
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg)
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil)
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
require.NoError(t, err)

View File

@@ -11,11 +11,10 @@ import (
// emits.
// Metadata keys attached by the daemon to session-warning SystemEvents.
// The notification text itself travels as a message key (see
// proto.UserMsgSessionExpiresIn); these keys carry the structured deadline
// the UI needs for its own countdown label, and disambiguate the
// T-WarningLead notification from the T-FinalWarningLead fallback that
// auto-opens the SessionAboutToExpire dialog.
// The UI tray reads these to build a locale-aware notification without
// relying on the daemon's locale-less UserMessage string, and to
// disambiguate the T-WarningLead notification from the T-FinalWarningLead
// fallback that auto-opens the SessionAboutToExpire dialog.
const (
// MetaSessionWarning is set to "true" on both warning events (T-10 and
// T-2) so the UI can detect a session-warning SystemEvent without
@@ -37,9 +36,10 @@ const (
// MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION
// SystemEvent the daemon emits when it discards a deadline from the
// management server (pre-epoch, too far in the future, or past the
// clock-skew tolerance). The value is the rejection reason string,
// which is diagnostic only: the user-facing text travels as
// proto.UserMsgSessionDeadlineReject.
// clock-skew tolerance). The value is the rejection reason string.
// userMessage is left empty; the UI detects the event via this key
// and builds a localized notification — same pattern as the session
// warnings above.
MetaSessionDeadlineRejected = "session_deadline_rejected"
)

View File

@@ -21,7 +21,6 @@ import (
log "github.com/sirupsen/logrus"
cProto "github.com/netbirdio/netbird/client/proto"
nbstatus "github.com/netbirdio/netbird/client/status"
)
const (
@@ -81,7 +80,7 @@ type StatusRecorder interface {
severity cProto.SystemEvent_Severity,
category cProto.SystemEvent_Category,
message string,
userMessage *cProto.UserMessage,
userMessage string,
metadata map[string]string,
)
}
@@ -377,22 +376,7 @@ func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
cProto.SystemEvent_CRITICAL,
cProto.SystemEvent_AUTHENTICATION,
message,
warningUserMessage(deadline),
"",
meta,
)
}
// warningUserMessage builds the localizable body for a session warning. The
// remaining time is rendered here rather than in the UI so every consumer of the
// event agrees on it; a deadline that is already gone (a warning delivered late)
// drops to the variant without a countdown.
func warningUserMessage(deadline time.Time) *cProto.UserMessage {
remaining := time.Until(deadline)
if remaining <= 0 {
return cProto.NewUserMessage(cProto.UserMsgSessionExpiresSoon).
WithTitle(cProto.TitleSessionWarning)
}
return cProto.NewUserMessage(cProto.UserMsgSessionExpiresIn,
cProto.ArgRemaining, nbstatus.HumaniseDuration(remaining)).
WithTitle(cProto.TitleSessionWarning)
}

View File

@@ -34,9 +34,6 @@ type event struct {
severity cProto.SystemEvent_Severity
category cProto.SystemEvent_Category
message string
msgKey cProto.UserMessageKey
titleKey cProto.UserMessageKey
msgArgs map[string]string
meta map[string]string
}
@@ -65,7 +62,7 @@ func (r *fakeRecorder) PublishEvent(
severity cProto.SystemEvent_Severity,
category cProto.SystemEvent_Category,
message string,
userMessage *cProto.UserMessage,
_ string,
metadata map[string]string,
) {
r.mu.Lock()
@@ -75,9 +72,6 @@ func (r *fakeRecorder) PublishEvent(
severity: severity,
category: category,
message: message,
msgKey: userMessage.Key(),
titleKey: userMessage.TitleKey(),
msgArgs: userMessage.Args(),
meta: metadata,
})
}
@@ -192,33 +186,6 @@ func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
}
}
// The UI localizes the warning body from the key rather than from the daemon's
// English text, so a warning that ships no key would silently regress to English.
func TestWarningCarriesLocalizableMessage(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
_ = w.Update(time.Now().Add(80 * time.Millisecond))
events := waitForEvents(t, r, 2)
warning := events[1]
if !warning.isWarning() {
t.Fatalf("event[1] should be a warning publish, got %+v", warning)
}
if warning.msgKey != cProto.UserMsgSessionExpiresIn {
t.Errorf("warning message key = %q, want %q", warning.msgKey, cProto.UserMsgSessionExpiresIn)
}
if warning.titleKey != cProto.TitleSessionWarning {
t.Errorf("warning title key = %q, want %q", warning.titleKey, cProto.TitleSessionWarning)
}
// The remaining time is rendered at publish time so every consumer of the
// event agrees on it; the exact value depends on timer slack.
if remaining := warning.msgArgs[cProto.ArgRemaining]; remaining == "" {
t.Errorf("warning is missing the %q argument, args=%v", cProto.ArgRemaining, warning.msgArgs)
}
}
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(time.Hour, r) // lead > delta => fire immediately

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,
@@ -163,7 +166,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
rec.PublishEvent(
cProto.SystemEvent_CRITICAL, cProto.SystemEvent_SYSTEM,
"panic occurred",
cProto.NewUserMessage(cProto.UserMsgPanic),
"The Netbird service panicked. Please restart the service and submit a bug report with the client logs.",
nil,
)
}

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

@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
ds.hostsDNSHolder.set(hostsDnsList)
ds.permanent = true
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
ds.searchDomainNotifier.setListener(listener)
setServerDns(ds)
return ds
@@ -602,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
}
func (s *DefaultServer) SearchDomains() []string {
s.mux.Lock()
defer s.mux.Unlock()
return s.searchDomains()
}
func (s *DefaultServer) searchDomains() []string {
var searchDomains []string
for _, dConf := range s.currentConfig.Domains {
@@ -686,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
}()
if s.searchDomainNotifier != nil {
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
}
s.updateNSGroupStates(update.NameServerGroups)
@@ -1134,7 +1140,7 @@ func (s *DefaultServer) projectHealthy(p *nsGroupProj, servers []netip.AddrPort)
proto.SystemEvent_INFO,
proto.SystemEvent_DNS,
"Nameserver group recovered",
proto.NewUserMessage(proto.UserMsgDNSRecovered),
"DNS servers are reachable again.",
map[string]string{"upstreams": joinAddrPorts(servers)},
)
p.warningActive = false
@@ -1157,7 +1163,7 @@ func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPor
proto.SystemEvent_WARNING,
proto.SystemEvent_DNS,
"Nameserver group unreachable",
proto.NewUserMessage(proto.UserMsgDNSUnreachable),
"Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
map[string]string{"upstreams": joinAddrPorts(servers)},
)
p.warningActive = true

View File

@@ -572,12 +572,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
}
e.stateManager.Start()
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
if err != nil {
return fmt.Errorf("read initial settings: %w", err)
}
dnsServer, err := e.newDnsServer(dnsConfig)
dnsServer, err := e.newDnsServer()
if err != nil {
return fmt.Errorf("create dns server: %w", err)
}
@@ -595,10 +590,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
WGInterface: e.wgInterface,
StatusRecorder: e.statusRecorder,
RelayManager: e.relayManager,
InitialRoutes: initialRoutes,
StateManager: e.stateManager,
DNSServer: dnsServer,
DNSFeatureFlag: dnsFeatureFlag,
PeerStore: e.peerStore,
DisableClientRoutes: e.config.DisableClientRoutes,
DisableServerRoutes: e.config.DisableServerRoutes,
@@ -1074,7 +1067,7 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return err
}
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", nil, nil)
e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", "", nil)
return nil
}
@@ -2102,42 +2095,6 @@ func (e *Engine) close() {
}
}
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
if runtime.GOOS != "android" {
// nolint:nilnil
return nil, nil, false, nil
}
info := system.GetInfo(e.ctx)
info.SetFlags(
e.config.RosenpassEnabled,
e.config.RosenpassPermissive,
&e.config.ServerSSHAllowed,
e.config.DisableClientRoutes,
e.config.DisableServerRoutes,
e.config.DisableDNS,
e.config.DisableFirewall,
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
e.config.SyncMessageVersion,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
e.config.EnableSSHRemotePortForwarding,
e.config.DisableSSHAuth,
)
netMap, err := e.mgmClient.GetNetworkMap(info)
if err != nil {
return nil, nil, false, err
}
routes := toRoutes(netMap.GetRoutes())
dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address())
dnsFeatureFlag := toDNSFeatureFlag(netMap)
return routes, &dnsCfg, dnsFeatureFlag, nil
}
func (e *Engine) newWgIface() (*iface.WGIface, error) {
transportNet, err := e.newStdNet()
if err != nil {
@@ -2172,7 +2129,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
func (e *Engine) wgInterfaceCreate() (err error) {
switch runtime.GOOS {
case "android":
err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains())
case "ios":
e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String())
if e.config.WgAddr.HasIPv6() {
@@ -2185,7 +2142,7 @@ func (e *Engine) wgInterfaceCreate() (err error) {
return err
}
func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
func (e *Engine) newDnsServer() (dns.Server, error) {
// due to tests where we are using a mocked version of the DNS server
if e.dnsServer != nil {
return e.dnsServer, nil
@@ -2197,7 +2154,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
e.ctx,
e.wgInterface,
e.mobileDep.HostDNSAddresses,
*dnsConfig,
nbdns.Config{},
e.mobileDep.NetworkChangeListener,
e.statusRecorder,
e.config.DisableDNS,

View File

@@ -57,8 +57,7 @@ func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) {
cProto.SystemEvent_ERROR,
cProto.SystemEvent_AUTHENTICATION,
"session deadline rejected",
cProto.NewUserMessage(cProto.UserMsgSessionDeadlineReject).
WithTitle(cProto.TitleSessionDeadlineReject),
"",
map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()},
)
}

View File

@@ -519,7 +519,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
if err != nil {
return nil, "", err

View File

@@ -0,0 +1,20 @@
package internal
func (e *Engine) TunSettings() ([]string, []string) {
e.syncMsgMux.Lock()
routeManager := e.routeManager
dnsServer := e.dnsServer
e.syncMsgMux.Unlock()
var routes []string
if routeManager != nil {
routes = routeManager.CurrentRouteRange()
}
var searchDomains []string
if dnsServer != nil {
searchDomains = dnsServer.SearchDomains()
}
return routes, searchDomains
}

View File

@@ -1281,15 +1281,12 @@ func (d *Status) numOfPeers() int {
return len(d.peers) + len(d.offlinePeers)
}
// PublishEvent adds an event to the queue and distributes it to all subscribers.
// msg is the English log-facing description; userMsg is the localizable
// user-facing message, or nil for an internal control event that must not
// surface as a notification.
// PublishEvent adds an event to the queue and distributes it to all subscribers
func (d *Status) PublishEvent(
severity proto.SystemEvent_Severity,
category proto.SystemEvent_Category,
msg string,
userMsg *proto.UserMessage,
userMsg string,
metadata map[string]string,
) {
event := &proto.SystemEvent{
@@ -1297,10 +1294,7 @@ func (d *Status) PublishEvent(
Severity: severity,
Category: category,
Message: msg,
UserMessage: userMsg.Text(),
MessageKey: string(userMsg.Key()),
MessageArgs: userMsg.Args(),
TitleKey: string(userMsg.TitleKey()),
UserMessage: userMsg,
Metadata: metadata,
Timestamp: timestamppb.Now(),
}

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

@@ -403,7 +403,7 @@ func (w *Watcher) connectEvent(route *route.Route) {
proto.SystemEvent_INFO,
proto.SystemEvent_NETWORK,
"Default route added",
proto.NewUserMessage(proto.UserMsgExitNodeConnected),
"Exit node connected.",
meta,
)
}
@@ -423,7 +423,7 @@ func (w *Watcher) disconnectEvent(route *route.Route, rsn reason) {
var severity proto.SystemEvent_Severity
var message string
var userMessage *proto.UserMessage
var userMessage string
meta := make(map[string]string)
if route != nil {
@@ -435,22 +435,22 @@ func (w *Watcher) disconnectEvent(route *route.Route, rsn reason) {
case reasonShutdown:
severity = proto.SystemEvent_INFO
message = "Default route removed"
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeDisconnected)
userMessage = "Exit node disconnected."
case reasonRouteUpdate:
severity = proto.SystemEvent_INFO
message = "Default route updated due to configuration change"
case reasonPeerUpdate:
severity = proto.SystemEvent_WARNING
message = "Default route disconnected due to peer unreachability"
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeConnectionLost)
userMessage = "Exit node connection lost. Your internet access might be affected."
case reasonHA:
severity = proto.SystemEvent_INFO
message = "Default route disconnected due to high availability change"
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeHAChange)
userMessage = "Exit node disconnected due to high availability change."
default:
severity = proto.SystemEvent_ERROR
message = "Default route disconnected for unknown reasons"
userMessage = proto.NewUserMessage(proto.UserMsgExitNodeDisconnectedUnknown)
userMessage = "Exit node disconnected for unknown reasons."
}
w.statusRecorder.PublishEvent(

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

@@ -8,14 +8,13 @@ import (
"net/netip"
"net/url"
"runtime"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/google/uuid"
"github.com/hashicorp/go-multierror"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
@@ -62,7 +61,7 @@ type Manager interface {
GetActiveClientRoutes() route.HAMap
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
SetRouteChangeListener(listener listener.NetworkChangeListener)
InitialRouteRange() []string
CurrentRouteRange() []string
SetFirewall(firewall.Manager) error
SetDNSForwarderPort(port uint16)
ReconcilePeerAllowedIPs(peerKey string) error
@@ -76,10 +75,8 @@ type ManagerConfig struct {
WGInterface iface.WGIface
StatusRecorder *peer.Status
RelayManager *relayClient.Manager
InitialRoutes []*route.Route
StateManager *statemanager.Manager
DNSServer dns.Server
DNSFeatureFlag bool
PeerStore *peerstore.Store
DisableClientRoutes bool
DisableServerRoutes bool
@@ -149,45 +146,12 @@ func NewManager(config ManagerConfig) *DefaultManager {
useNoop := netstack.IsEnabled() || config.DisableClientRoutes
dm.setupRefCounters(useNoop)
// don't proceed with client routes if it is disabled
if config.DisableClientRoutes {
return dm
}
if runtime.GOOS == "android" {
dm.setupAndroidRoutes(config)
}
return dm
}
func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
cr := m.initialClientRoutes(config.InitialRoutes)
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})
}
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
func (m *DefaultManager) enableFakeIPRoutes() {
m.fakeIPManager = fakeip.NewManager()
m.notifier.NotifyRouteChange()
}
func (m *DefaultManager) setupRefCounters(useNoop bool) {
@@ -464,6 +428,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)
@@ -500,9 +467,32 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL
m.notifier.SetListener(listener)
}
// InitialRouteRange return the list of initial routes. It used by mobile systems
func (m *DefaultManager) InitialRouteRange() []string {
return m.notifier.GetInitialRouteRanges()
// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
func (m *DefaultManager) CurrentRouteRange() []string {
m.mux.Lock()
defer m.mux.Unlock()
if m.disableClientRoutes {
return nil
}
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
var nets []string
for _, routes := range filtered {
for _, r := range routes {
if r.IsDynamic() {
continue
}
nets = append(nets, r.NetString())
}
}
if m.fakeIPManager != nil {
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
}
sort.Strings(nets)
return nets
}
// GetRouteSelector returns the route selector
@@ -700,16 +690,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]
return newServerRoutesMap, newClientRoutesIDMap
}
func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route {
_, crMap := m.ClassifyRoutes(initialRoutes)
rs := make([]*route.Route, 0, len(crMap))
for _, routes := range crMap {
rs = append(rs, routes...)
}
return rs
}
func isRouteSupported(route *route.Route) bool {
if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() {
return true

View File

@@ -30,8 +30,8 @@ func (m *MockManager) Init() error {
return nil
}
// InitialRouteRange mock implementation of InitialRouteRange from Manager interface
func (m *MockManager) InitialRouteRange() []string {
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
func (m *MockManager) CurrentRouteRange() []string {
return nil
}

View File

@@ -6,7 +6,6 @@ import (
"net/netip"
"slices"
"sort"
"strings"
"sync"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -14,12 +13,15 @@ import (
)
type Notifier struct {
initialRoutes []*route.Route
currentRoutes []*route.Route
fakeIPRoutes []*route.Route
mu sync.Mutex
listener listener.NetworkChangeListener
listenerMux sync.Mutex
// currentRoutes is the last announced route set. It exists only to
// suppress noise: without it every network map sync would trigger the
// Java side, even when the routes did not change. The actual TUN route
// state is owned by the route manager and pulled from there.
currentRoutes []*route.Route
listener listener.NetworkChangeListener
}
func NewNotifier() *Notifier {
@@ -27,20 +29,15 @@ func NewNotifier() *Notifier {
}
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
n.listenerMux.Lock()
defer n.listenerMux.Unlock()
n.mu.Lock()
defer n.mu.Unlock()
n.listener = listener
}
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
n.initialRoutes = filterStatic(initialRoutes)
n.currentRoutes = filterStatic(routesForComparison)
}
// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild.
func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) {
n.fakeIPRoutes = routes
func (n *Notifier) NotifyRouteChange() {
n.mu.Lock()
defer n.mu.Unlock()
n.notifyLocked()
}
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
@@ -54,46 +51,32 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
}
}
if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
n.mu.Lock()
defer n.mu.Unlock()
if !hasRouteDiff(n.currentRoutes, newRoutes) {
return
}
n.currentRoutes = newRoutes
n.notify()
n.notifyLocked()
}
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
// Not used on Android
}
func (n *Notifier) notify() {
n.listenerMux.Lock()
defer n.listenerMux.Unlock()
func (n *Notifier) notifyLocked() {
if n.listener == nil {
return
}
allRoutes := slices.Clone(n.currentRoutes)
allRoutes = append(allRoutes, n.fakeIPRoutes...)
routeStrings := n.routesToStrings(allRoutes)
sort.Strings(routeStrings)
go func(l listener.NetworkChangeListener) {
l.OnNetworkChanged(strings.Join(routeStrings, ","))
}(n.listener)
n.listener.OnNetworkChanged("")
}
func filterStatic(routes []*route.Route) []*route.Route {
out := make([]*route.Route, 0, len(routes))
for _, r := range routes {
if !r.IsDynamic() {
out = append(out, r)
}
}
return out
func (n *Notifier) Close() {
// unused
}
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
func routesToStrings(routes []*route.Route) []string {
nets := make([]string, 0, len(routes))
for _, r := range routes {
nets = append(nets, r.NetString())
@@ -101,25 +84,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
return nets
}
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()
})
}
func (n *Notifier) GetInitialRouteRanges() []string {
initialStrings := n.routesToStrings(n.initialRoutes)
sort.Strings(initialStrings)
return initialStrings
}
func (n *Notifier) Close() {
// unused
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
as := routesToStrings(a)
bs := routesToStrings(b)
sort.Strings(as)
sort.Strings(bs)
return !slices.Equal(as, bs)
}

View File

@@ -29,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
n.listener = listener
}
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
// iOS doesn't care about initial routes
}
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
func (n *Notifier) NotifyRouteChange() {
// Not used on iOS
}

View File

@@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
// Not used on non-mobile platforms
}
func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
// Not used on non-mobile platforms
}
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
func (n *Notifier) NotifyRouteChange() {
// Not used on non-mobile platforms
}
@@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
// Not used on non-mobile platforms
}
func (n *Notifier) GetInitialRouteRanges() []string {
return []string{}
}
func (n *Notifier) Close() {
// unused
}

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

@@ -94,7 +94,7 @@ func (m *Manager) CheckUpdateSuccess(ctx context.Context) {
cProto.SystemEvent_ERROR,
cProto.SystemEvent_SYSTEM,
"Auto-update failed",
cProto.NewUserMessage(cProto.UserMsgUpdateFailed, cProto.ArgReason, reason),
fmt.Sprintf("Auto-update failed: %s", reason),
nil,
)
}
@@ -115,7 +115,7 @@ func (m *Manager) CheckUpdateSuccess(ctx context.Context) {
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"Auto-update completed",
cProto.NewUserMessage(cProto.UserMsgUpdateCompleted, cProto.ArgVersion, m.currentVersion),
fmt.Sprintf("Your NetBird Client was auto-updated to version %s", m.currentVersion),
nil,
)
return
@@ -272,7 +272,7 @@ func (m *Manager) NotifyUI() {
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"New version available",
nil,
"",
map[string]string{"new_version_available": latestVersion.String()},
)
return
@@ -283,7 +283,7 @@ func (m *Manager) NotifyUI() {
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"New version available",
nil,
"",
map[string]string{"new_version_available": pendingVersion.String(), "enforced": "true"},
)
}
@@ -384,7 +384,7 @@ func (m *Manager) handleUpdate(ctx context.Context) {
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"New version available",
nil,
"",
map[string]string{"new_version_available": updateVersion.String()},
)
return
@@ -401,7 +401,7 @@ func (m *Manager) handleUpdate(ctx context.Context) {
cProto.SystemEvent_INFO,
cProto.SystemEvent_SYSTEM,
"New version available",
nil,
"",
map[string]string{"new_version_available": updateVersion.String(), "enforced": "true"},
)
}
@@ -411,14 +411,14 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error
cProto.SystemEvent_CRITICAL,
cProto.SystemEvent_SYSTEM,
"Updating client",
cProto.NewUserMessage(cProto.UserMsgUpdateInstalling),
"Installing update now.",
nil,
)
m.statusRecorder.PublishEvent(
cProto.SystemEvent_CRITICAL,
cProto.SystemEvent_SYSTEM,
"",
nil,
"",
map[string]string{"progress_window": "show", "version": pendingVersion.String()},
)
@@ -441,7 +441,7 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error
cProto.SystemEvent_ERROR,
cProto.SystemEvent_SYSTEM,
"Auto-update failed",
cProto.NewUserMessage(cProto.UserMsgUpdateFailed, cProto.ArgReason, err.Error()),
fmt.Sprintf("Auto-update failed: %v", err),
nil,
)
return err

View File

@@ -3909,30 +3909,14 @@ func (*SubscribeRequest) Descriptor() ([]byte, []int) {
}
type SystemEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Severity SystemEvent_Severity `protobuf:"varint,2,opt,name=severity,proto3,enum=daemon.SystemEvent_Severity" json:"severity,omitempty"`
Category SystemEvent_Category `protobuf:"varint,3,opt,name=category,proto3,enum=daemon.SystemEvent_Category" json:"category,omitempty"`
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
// userMessage is the daemon's English rendering of messageKey, kept for the
// CLI and for UIs that predate messageKey. UIs that localise read messageKey
// and treat this as the fallback.
UserMessage string `protobuf:"bytes,5,opt,name=userMessage,proto3" json:"userMessage,omitempty"`
Timestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// messageKey names a user-facing message in a stable, locale-independent
// form. A UI resolves it against its own translation bundle and substitutes
// messageArgs; an unrecognised key falls back to userMessage. Empty on events
// that carry no user-facing text.
MessageKey string `protobuf:"bytes,8,opt,name=messageKey,proto3" json:"messageKey,omitempty"`
// messageArgs holds the placeholder name/value pairs for messageKey, e.g.
// {"version": "0.60.1"} for a "{version}" template. Values are data the
// daemon cannot localise (versions, addresses, error text).
MessageArgs map[string]string `protobuf:"bytes,9,rep,name=messageArgs,proto3" json:"messageArgs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// titleKey names the notification title in the same way as messageKey. Empty
// when the event has no dedicated title, in which case a UI composes one from
// severity and category.
TitleKey string `protobuf:"bytes,10,opt,name=titleKey,proto3" json:"titleKey,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Severity SystemEvent_Severity `protobuf:"varint,2,opt,name=severity,proto3,enum=daemon.SystemEvent_Severity" json:"severity,omitempty"`
Category SystemEvent_Category `protobuf:"varint,3,opt,name=category,proto3,enum=daemon.SystemEvent_Category" json:"category,omitempty"`
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
UserMessage string `protobuf:"bytes,5,opt,name=userMessage,proto3" json:"userMessage,omitempty"`
Timestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4016,27 +4000,6 @@ func (x *SystemEvent) GetMetadata() map[string]string {
return nil
}
func (x *SystemEvent) GetMessageKey() string {
if x != nil {
return x.MessageKey
}
return ""
}
func (x *SystemEvent) GetMessageArgs() map[string]string {
if x != nil {
return x.MessageArgs
}
return nil
}
func (x *SystemEvent) GetTitleKey() string {
if x != nil {
return x.TitleKey
}
return ""
}
type GetEventsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -7369,7 +7332,7 @@ const file_daemon_proto_rawDesc = "" +
"\x13TracePacketResponse\x12*\n" +
"\x06stages\x18\x01 \x03(\v2\x12.daemon.TraceStageR\x06stages\x12+\n" +
"\x11final_disposition\x18\x02 \x01(\bR\x10finalDisposition\"\x12\n" +
"\x10SubscribeRequest\"\xd7\x05\n" +
"\x10SubscribeRequest\"\x93\x04\n" +
"\vSystemEvent\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x128\n" +
"\bseverity\x18\x02 \x01(\x0e2\x1c.daemon.SystemEvent.SeverityR\bseverity\x128\n" +
@@ -7377,18 +7340,9 @@ const file_daemon_proto_rawDesc = "" +
"\amessage\x18\x04 \x01(\tR\amessage\x12 \n" +
"\vuserMessage\x18\x05 \x01(\tR\vuserMessage\x128\n" +
"\ttimestamp\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12=\n" +
"\bmetadata\x18\a \x03(\v2!.daemon.SystemEvent.MetadataEntryR\bmetadata\x12\x1e\n" +
"\n" +
"messageKey\x18\b \x01(\tR\n" +
"messageKey\x12F\n" +
"\vmessageArgs\x18\t \x03(\v2$.daemon.SystemEvent.MessageArgsEntryR\vmessageArgs\x12\x1a\n" +
"\btitleKey\x18\n" +
" \x01(\tR\btitleKey\x1a;\n" +
"\bmetadata\x18\a \x03(\v2!.daemon.SystemEvent.MetadataEntryR\bmetadata\x1a;\n" +
"\rMetadataEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" +
"\x10MessageArgsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\":\n" +
"\bSeverity\x12\b\n" +
"\x04INFO\x10\x00\x12\v\n" +
@@ -7706,7 +7660,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 111)
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
@@ -7822,17 +7776,16 @@ var file_daemon_proto_goTypes = []any{
nil, // 111: daemon.Network.ResolvedIPsEntry
(*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range
nil, // 113: daemon.SystemEvent.MetadataEntry
nil, // 114: daemon.SystemEvent.MessageArgsEntry
(*durationpb.Duration)(nil), // 115: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 116: google.protobuf.Timestamp
(*durationpb.Duration)(nil), // 114: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
115, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
116, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
116, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
116, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
115, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
@@ -7855,115 +7808,114 @@ var file_daemon_proto_depIdxs = []int32{
54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
116, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
114, // 30: daemon.SystemEvent.messageArgs:type_name -> daemon.SystemEvent.MessageArgsEntry
57, // 31: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
115, // 32: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
72, // 33: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
116, // 34: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
1, // 35: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
104, // 36: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
115, // 37: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
115, // 38: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
30, // 39: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
5, // 40: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
7, // 41: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
9, // 42: daemon.DaemonService.Up:input_type -> daemon.UpRequest
11, // 43: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
11, // 44: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
13, // 45: daemon.DaemonService.Down:input_type -> daemon.DownRequest
15, // 46: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
26, // 47: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
28, // 48: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
28, // 49: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
4, // 50: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
35, // 51: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
37, // 52: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
39, // 53: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
44, // 54: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
46, // 55: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
48, // 56: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
50, // 57: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
53, // 58: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
105, // 59: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
107, // 60: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
109, // 61: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
56, // 62: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
58, // 63: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
41, // 64: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
60, // 65: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
62, // 66: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
64, // 67: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
66, // 68: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
68, // 69: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
70, // 70: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
73, // 71: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
75, // 72: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
79, // 73: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
82, // 74: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
84, // 75: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
86, // 76: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
88, // 77: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
90, // 78: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
92, // 79: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
94, // 80: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
96, // 81: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
98, // 82: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
100, // 83: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
102, // 84: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
77, // 85: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
6, // 86: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
8, // 87: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
10, // 88: daemon.DaemonService.Up:output_type -> daemon.UpResponse
12, // 89: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
12, // 90: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
14, // 91: daemon.DaemonService.Down:output_type -> daemon.DownResponse
16, // 92: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
27, // 93: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
29, // 94: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
29, // 95: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
34, // 96: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
36, // 97: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
38, // 98: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
40, // 99: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
45, // 100: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
47, // 101: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
49, // 102: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
51, // 103: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
55, // 104: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
106, // 105: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
108, // 106: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
110, // 107: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
57, // 108: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
59, // 109: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
42, // 110: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
61, // 111: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
63, // 112: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
65, // 113: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
67, // 114: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
69, // 115: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
71, // 116: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
74, // 117: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
76, // 118: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
80, // 119: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
83, // 120: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
85, // 121: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
87, // 122: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
89, // 123: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
91, // 124: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
93, // 125: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
95, // 126: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
97, // 127: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
99, // 128: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
101, // 129: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
103, // 130: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
78, // 131: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
86, // [86:132] is the sub-list for method output_type
40, // [40:86] is the sub-list for method input_type
40, // [40:40] is the sub-list for extension type_name
40, // [40:40] is the sub-list for extension extendee
0, // [0:40] is the sub-list for field type_name
57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest
11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest
15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest
60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest
92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest
94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest
96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest
6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse
12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse
16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse
61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse
93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse
95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse
97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse
85, // [85:131] is the sub-list for method output_type
39, // [39:85] is the sub-list for method input_type
39, // [39:39] is the sub-list for extension type_name
39, // [39:39] is the sub-list for extension extendee
0, // [0:39] is the sub-list for field type_name
}
func init() { file_daemon_proto_init() }
@@ -7995,7 +7947,7 @@ func file_daemon_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 4,
NumMessages: 111,
NumMessages: 110,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -677,25 +677,9 @@ message SystemEvent {
Severity severity = 2;
Category category = 3;
string message = 4;
// userMessage is the daemon's English rendering of messageKey, kept for the
// CLI and for UIs that predate messageKey. UIs that localise read messageKey
// and treat this as the fallback.
string userMessage = 5;
google.protobuf.Timestamp timestamp = 6;
map<string, string> metadata = 7;
// messageKey names a user-facing message in a stable, locale-independent
// form. A UI resolves it against its own translation bundle and substitutes
// messageArgs; an unrecognised key falls back to userMessage. Empty on events
// that carry no user-facing text.
string messageKey = 8;
// messageArgs holds the placeholder name/value pairs for messageKey, e.g.
// {"version": "0.60.1"} for a "{version}" template. Values are data the
// daemon cannot localise (versions, addresses, error text).
map<string, string> messageArgs = 9;
// titleKey names the notification title in the same way as messageKey. Empty
// when the event has no dedicated title, in which case a UI composes one from
// severity and category.
string titleKey = 10;
}
message GetEventsRequest {}

View File

@@ -43,10 +43,10 @@ const (
// UIs to re-fetch their cached config + features. UserMessage is empty so
// the change is silent; the source is carried in MetadataSourceKey.
MetadataTypeConfigChanged = "config_changed"
// MetadataTypePolicyApplied marks an MDM-policy-driven config change. It is
// the user-facing half of the pair: the daemon stamps it with the message
// and title keys a UI localises, while the paired config_changed event stays
// silent and only drives the cache refresh.
// MetadataTypePolicyApplied marks an MDM-policy-driven config change. The
// daemon stamps it with a (non-localised) UserMessage; the UI suppresses
// that and builds its own localised toast off the paired config_changed
// event instead.
MetadataTypePolicyApplied = "policy_applied"
// MetadataSourceKey is the SystemEvent.metadata key carrying what

View File

@@ -1,183 +0,0 @@
package proto
import (
"strings"
log "github.com/sirupsen/logrus"
)
// UserMessageKey names a piece of user-facing SystemEvent text in a
// locale-independent form. The daemon has no notion of the user's language, so it
// publishes the key and lets each UI resolve it.
//
// The value doubles as the lookup key in the desktop UI's translation bundles
// (client/ui/i18n/locales/<code>/common.json), so renaming a constant here means
// renaming the key in every bundle. The tests in client/ui/i18n lock the two
// sides together. Keys that predate this mechanism keep their original bundle
// names so the shipped translations still apply.
type UserMessageKey string
// Message-body keys published by the daemon. Every key needs an entry in
// UserMessageTexts below and a translation in the UI bundles.
const (
// UserMsgPanic is the CRITICAL event published from the recover() guard in
// the connect loop.
UserMsgPanic UserMessageKey = "event.panic"
// UserMsgDNSRecovered and UserMsgDNSUnreachable bracket a nameserver
// group's health transitions.
UserMsgDNSRecovered UserMessageKey = "event.dns.recovered"
UserMsgDNSUnreachable UserMessageKey = "event.dns.unreachable"
// Exit-node (default route) transitions.
UserMsgExitNodeConnected UserMessageKey = "event.exitNode.connected"
UserMsgExitNodeDisconnected UserMessageKey = "event.exitNode.disconnected"
UserMsgExitNodeConnectionLost UserMessageKey = "event.exitNode.connectionLost"
UserMsgExitNodeHAChange UserMessageKey = "event.exitNode.haChange"
UserMsgExitNodeDisconnectedUnknown UserMessageKey = "event.exitNode.disconnectedUnknown"
// Auto-update lifecycle. UserMsgUpdateFailed takes a {reason} argument and
// UserMsgUpdateCompleted a {version}.
UserMsgUpdateInstalling UserMessageKey = "event.update.installing"
UserMsgUpdateCompleted UserMessageKey = "event.update.completed"
UserMsgUpdateFailed UserMessageKey = "event.update.failed"
// UserMsgMDMPolicyApplied reports that an MDM policy replaced the config.
UserMsgMDMPolicyApplied UserMessageKey = "notify.mdm.policyApplied.body"
// Session-expiry events. UserMsgSessionExpiresIn takes a {remaining}
// argument; the "soon" variant is published when the deadline has already
// passed by the time the warning fires.
UserMsgSessionExpiresIn UserMessageKey = "notify.sessionWarning.body"
UserMsgSessionExpiresSoon UserMessageKey = "notify.sessionWarning.bodyGeneric"
UserMsgSessionDeadlineReject UserMessageKey = "notify.sessionDeadlineRejected.body"
)
// Notification-title keys. An event without one leaves titleKey empty and the UI
// composes a title from severity and category, which is what every event did
// before message keys existed. These carry no English fallback: a UI old enough
// to ignore titleKey already builds its own title.
const (
TitleMDMPolicyApplied UserMessageKey = "notify.mdm.policyApplied.title"
TitleSessionWarning UserMessageKey = "notify.sessionWarning.title"
TitleSessionDeadlineReject UserMessageKey = "notify.sessionDeadlineRejected.title"
)
// UserMessageTitleKeys lists every title key a daemon can publish. It sits next
// to the constants above because the two must grow together: a title key missing
// from this slice ships untranslated, and the i18n test that would have caught it
// reads this list.
var UserMessageTitleKeys = []UserMessageKey{
TitleMDMPolicyApplied,
TitleSessionWarning,
TitleSessionDeadlineReject,
}
// ArgReason, ArgVersion and ArgRemaining are the placeholder names used by the
// templates below. Producers pass them to NewUserMessage; the UI bundles use the
// same names inside {}.
const (
ArgReason = "reason"
ArgVersion = "version"
ArgRemaining = "remaining"
)
// UserMessageTexts is the English rendering of every body key, and the source of
// the SystemEvent.userMessage fallback that the CLI and pre-messageKey UIs read.
// It must match the en bundle, which the i18n test enforces.
var UserMessageTexts = map[UserMessageKey]string{
UserMsgPanic: "The NetBird service panicked. Please restart the service and submit a bug report with the client logs.",
UserMsgDNSRecovered: "DNS servers are reachable again.",
UserMsgDNSUnreachable: "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
UserMsgExitNodeConnected: "Exit node connected.",
UserMsgExitNodeDisconnected: "Exit node disconnected.",
UserMsgExitNodeConnectionLost: "Exit node connection lost. Your internet access might be affected.",
UserMsgExitNodeHAChange: "Exit node disconnected due to high availability change.",
UserMsgExitNodeDisconnectedUnknown: "Exit node disconnected for unknown reasons.",
UserMsgUpdateInstalling: "Installing update now.",
UserMsgUpdateCompleted: "Your NetBird client was auto-updated to version {version}.",
UserMsgUpdateFailed: "Auto-update failed: {reason}",
UserMsgMDMPolicyApplied: "Your NetBird configuration was updated by your IT policy.",
UserMsgSessionExpiresIn: "Your NetBird session expires in {remaining}. Click Extend now to renew.",
UserMsgSessionExpiresSoon: "Your NetBird session is about to expire. Click Extend now to renew.",
UserMsgSessionDeadlineReject: "The server sent an invalid session deadline. Please sign in again.",
}
// UserMessage is a localizable user-facing event message: a stable body key, an
// optional title key, and the placeholder values to substitute. A nil
// *UserMessage carries no user-facing text, which is how internal control events
// are published.
type UserMessage struct {
key UserMessageKey
title UserMessageKey
args map[string]string
}
// NewUserMessage builds a UserMessage from key and flat placeholder name/value
// pairs, e.g. NewUserMessage(UserMsgUpdateCompleted, ArgVersion, "0.60.1"). An
// unpaired trailing argument is dropped.
func NewUserMessage(key UserMessageKey, args ...string) *UserMessage {
m := &UserMessage{key: key}
if len(args)%2 != 0 {
log.Debugf("user message %q: placeholder args not paired: %d items, last dropped", key, len(args))
args = args[:len(args)-1]
}
if len(args) > 0 {
m.args = make(map[string]string, len(args)/2)
for i := 0; i < len(args); i += 2 {
m.args[args[i]] = args[i+1]
}
}
return m
}
// WithTitle attaches a notification title key and returns m, so it chains onto
// NewUserMessage.
func (m *UserMessage) WithTitle(key UserMessageKey) *UserMessage {
m.title = key
return m
}
// Key returns the body key, or the empty key for a nil message.
func (m *UserMessage) Key() UserMessageKey {
if m == nil {
return ""
}
return m.key
}
// TitleKey returns the title key, which is empty unless WithTitle was called.
func (m *UserMessage) TitleKey() UserMessageKey {
if m == nil {
return ""
}
return m.title
}
// Args returns the placeholder values, or nil for a nil message. The map is the
// message's own and must not be mutated by the caller; it is handed straight to
// SystemEvent.messageArgs.
func (m *UserMessage) Args() map[string]string {
if m == nil {
return nil
}
return m.args
}
// Text renders the English fallback for m, with placeholders substituted. A nil
// message, or a key with no registered template, renders empty so a UI treats
// the event as carrying no user-facing text.
func (m *UserMessage) Text() string {
if m == nil {
return ""
}
tmpl, ok := UserMessageTexts[m.key]
if !ok {
log.Warnf("no English template for user message key %q", m.key)
return ""
}
for name, value := range m.args {
tmpl = strings.ReplaceAll(tmpl, "{"+name+"}", value)
}
return tmpl
}

View File

@@ -1,91 +0,0 @@
package proto
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestUserMessageText(t *testing.T) {
tests := []struct {
name string
msg *UserMessage
want string
}{
{
name: "no placeholders",
msg: NewUserMessage(UserMsgExitNodeConnected),
want: "Exit node connected.",
},
{
name: "placeholder substituted",
msg: NewUserMessage(UserMsgUpdateCompleted, ArgVersion, "0.60.1"),
want: "Your NetBird client was auto-updated to version 0.60.1.",
},
{
// A dangling arg is a caller mistake; dropping it must still yield a
// readable sentence rather than panicking on the wire path.
name: "unpaired trailing arg dropped",
msg: NewUserMessage(UserMsgUpdateFailed, ArgReason, "disk full", "extra"),
want: "Auto-update failed: disk full",
},
{
name: "unknown key renders empty so the UI treats the event as silent",
msg: NewUserMessage("event.notRegistered"),
want: "",
},
{
name: "nil message carries no text",
msg: nil,
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, tc.msg.Text())
})
}
}
// A nil *UserMessage is how every internal control event is published, so the
// accessors must stay usable without a nil check at each of the ~25 call sites.
func TestNilUserMessageAccessors(t *testing.T) {
var msg *UserMessage
assert.Empty(t, msg.Key(), "nil message must have no body key")
assert.Empty(t, msg.TitleKey(), "nil message must have no title key")
assert.Nil(t, msg.Args(), "nil message must have no args")
assert.Empty(t, msg.Text(), "nil message must render empty")
}
func TestUserMessageArgsAndTitle(t *testing.T) {
msg := NewUserMessage(UserMsgSessionExpiresIn, ArgRemaining, "10m").
WithTitle(TitleSessionWarning)
assert.Equal(t, UserMsgSessionExpiresIn, msg.Key())
assert.Equal(t, TitleSessionWarning, msg.TitleKey())
assert.Equal(t, map[string]string{ArgRemaining: "10m"}, msg.Args())
assert.Equal(t, "Your NetBird session expires in 10m. Click Extend now to renew.", msg.Text())
}
// Every registered template must be reachable: a key whose text is empty would
// publish an event with a key but no fallback for the CLI and older UIs.
func TestUserMessageTextsAreNonEmpty(t *testing.T) {
texts := UserMessageTexts
assert.NotEmpty(t, texts, "the catalog must not be empty")
for key, text := range texts {
assert.NotEmpty(t, text, "message key %q has an empty template", key)
assert.NotEmpty(t, key, "the catalog must not contain an empty key")
}
}
func TestUserMessageTitleKeysAreUnique(t *testing.T) {
seen := make(map[UserMessageKey]struct{})
for _, key := range UserMessageTitleKeys {
assert.NotEmpty(t, key, "title keys must not be empty")
_, dup := seen[key]
assert.False(t, dup, "title key %q listed twice", key)
seen[key] = struct{}{}
}
}

View File

@@ -0,0 +1,110 @@
package server
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"github.com/stretchr/testify/require"
"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.isLoginRequiredFn = func(context.Context) (bool, error) {
attempts++
return false, 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)
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return true, nil
}
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
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")
}
func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return true, nil
}
var keysTried []string
s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) {
keysTried = append(keysTried, setupKey)
return "", nil
}
setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F"
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey})
require.NoError(t, err, "the probe's outcome leaked out as the login result")
require.NotNil(t, resp)
require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt")
require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow")
status, err := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, err)
require.Equal(t, internal.StatusIdle, status)
}
// breakProfilePrivateKey replaces the profile's private key with an unparseable
// one, which makes any attempt to build a Management client fail on the spot.
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
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

@@ -94,12 +94,12 @@ func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
// publishConfigChangedEvent has already fired inside
// restartEngineForMDMLocked with source="mdm". Emit an MDM-specific
// user-visible toast so the operator knows their IT policy was
// applied; the message and title keys let the GUI localise it.
// applied (UserMessage != "" triggers the GUI notifier).
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"MDM policy applied",
proto.NewUserMessage(proto.UserMsgMDMPolicyApplied).WithTitle(proto.TitleMDMPolicyApplied),
"NetBird configuration was updated by your IT policy.",
map[string]string{
proto.MetadataSourceKey: proto.MetadataSourceMDM,
proto.MetadataTypeKey: proto.MetadataTypePolicyApplied,
@@ -126,7 +126,7 @@ func (s *Server) publishConfigChangedEvent(source string) {
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
fmt.Sprintf("daemon config changed (source=%s)", source),
nil,
"",
map[string]string{
proto.MetadataSourceKey: source,
proto.MetadataTypeKey: proto.MetadataTypeConfigChanged,

View File

@@ -170,7 +170,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Network selection changed",
nil,
"",
map[string]string{
"networks": strings.Join(req.GetNetworkIDs(), ", "),
"append": fmt.Sprint(req.GetAppend()),
@@ -214,7 +214,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Network deselection changed",
nil,
"",
map[string]string{
"networks": strings.Join(req.GetNetworkIDs(), ", "),
"append": fmt.Sprint(req.GetAppend()),

View File

@@ -135,6 +135,13 @@ 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)
isLoginRequiredFn func(ctx context.Context) (bool, error)
}
type oauthAuthFlow struct {
@@ -370,7 +377,34 @@ 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)
}
func (s *Server) isLoginRequired(ctx context.Context) (bool, error) {
if s.isLoginRequiredFn != nil {
return s.isLoginRequiredFn(ctx)
}
authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config)
if err != nil {
log.Errorf("failed to create auth client: %v", err)
return false, err
}
defer authClient.Close()
return authClient.IsLoginRequired(ctx)
}
// loginAttempt attempts to login using the provided information. It returns
// StatusNeedsLogin when Management refused the peer's credentials and
// StatusLoginFailed for every other failure, so callers can tell an
// 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,7 +657,19 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
s.config = config
s.mutex.Unlock()
if _, err := s.loginAttempt(ctx, "", ""); err == nil {
// A probe that errors leaves the login undecided: Management unreachable, a
// restart mid-request, an internal error. Those are returned for the caller
// to retry, because turning them into an SSO prompt asks the user to solve
// something that is not theirs to solve, and a browser login cannot succeed
// while Management is unreachable anyway. Only Management refusing the
// peer's key is a decision, and IsLoginRequired reports that as
// needsLogin=true rather than an error.
needsLogin, err := s.isLoginRequired(ctx)
if err != nil {
state.Set(internal.StatusLoginFailed)
return nil, err
}
if !needsLogin {
state.Set(internal.StatusIdle)
return &proto.LoginResponse{}, nil
}
@@ -684,7 +730,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 +885,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
}
@@ -1769,6 +1815,9 @@ func (s *Server) RequestExtendAuthSession(
if connectClient == nil {
return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running")
}
if connectClient.Engine() == nil {
return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect")
}
hint := ""
if msg.Hint != nil {
@@ -2181,7 +2230,7 @@ func (s *Server) publishProfileListChanged(profileName string) {
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Profile list changed",
nil,
"",
map[string]string{proto.MetadataKindKey: proto.MetadataKindProfileListChanged, proto.MetadataProfileKey: profileName},
)
}
@@ -2200,7 +2249,7 @@ func (s *Server) publishLogLevelChanged(level string) {
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Log level changed",
nil,
"",
map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level},
)
}

View File

@@ -200,7 +200,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil {
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

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

View File

@@ -40,12 +40,6 @@ i18n/locales/<code>/common.json a target — message only
Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**.
The `event.*` keys are a special group: the background service names them when it
publishes a notification, and the app looks them up here. Their names are part of
a Go↔JSON contract (`client/proto/usermsg.go`), so they are even less renameable
than the rest — and a missing one shows the user English. Tests fail the build if
any locale drops one.
| ✅ Do | ❌ Don't |
|---|---|
| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) |

View File

@@ -126,29 +126,18 @@ func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) {
// pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to
// the default language, then to the key itself so a miss is visible in the UI.
func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string {
if v, ok := b.Lookup(lang, key, args...); ok {
return v
}
return key
}
// Lookup resolves key like Translate but reports whether it was found in the
// requested or the default bundle. Callers holding a better fallback than the
// raw key — a daemon-supplied English string for a key this build predates —
// use this to tell a miss from a hit.
func (b *Bundle) Lookup(lang LanguageCode, key string, args ...string) (string, bool) {
b.mu.RLock()
defer b.mu.RUnlock()
if v, ok := b.bundles[lang][key]; ok {
return applyPlaceholders(v, args), true
return applyPlaceholders(v, args)
}
if lang != DefaultLanguage {
if v, ok := b.bundles[DefaultLanguage][key]; ok {
return applyPlaceholders(v, args), true
return applyPlaceholders(v, args)
}
}
return "", false
return key
}
// applyPlaceholders substitutes {name} in s using args as flat name/value

View File

@@ -1,126 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package i18n
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
)
// shippedBundle loads the real locale tree rather than the fstest fixture the
// other tests use: these checks exist to catch a key the daemon publishes but no
// bundle translates, which only the shipped files can prove.
func shippedBundle(t *testing.T) *Bundle {
t.Helper()
b, err := NewBundle(os.DirFS("locales"))
require.NoError(t, err, "the shipped locale tree must load")
return b
}
// The daemon publishes a message key and each UI resolves it locally, so a key
// with no en entry degrades to the daemon's English fallback and silently stops
// being translatable. Fail the build instead.
func TestUserMessageKeysExistInEnglishBundle(t *testing.T) {
b := shippedBundle(t)
for key, text := range proto.UserMessageTexts {
got, ok := b.Lookup(DefaultLanguage, string(key))
if !assert.True(t, ok, "message key %q has no en translation", key) {
continue
}
assert.Equal(t, text, got,
"en translation of %q must match the daemon's English fallback", key)
}
}
func TestUserMessageTitleKeysExistInEnglishBundle(t *testing.T) {
b := shippedBundle(t)
for _, key := range proto.UserMessageTitleKeys {
_, ok := b.Lookup(DefaultLanguage, string(key))
assert.True(t, ok, "title key %q has no en translation", key)
}
}
// Every shipped locale must translate the daemon's keys, not just en. A missing
// one still renders (Lookup falls back to en) but the notification would show up
// in English for that user, which is the bug this whole mechanism exists to fix.
func TestUserMessageKeysTranslatedInEveryLanguage(t *testing.T) {
b := shippedBundle(t)
keys := make([]proto.UserMessageKey, 0, len(proto.UserMessageTexts))
for key := range proto.UserMessageTexts {
keys = append(keys, key)
}
keys = append(keys, proto.UserMessageTitleKeys...)
for _, lang := range b.Languages() {
bundle, err := b.BundleFor(lang.Code)
require.NoError(t, err, "BundleFor(%q)", lang.Code)
for _, key := range keys {
text, ok := bundle[string(key)]
if !assert.True(t, ok, "locale %q is missing key %q", lang.Code, key) {
continue
}
assert.NotEmpty(t, text, "locale %q has an empty message for %q", lang.Code, key)
}
}
}
// The tray composes a title from these when an event carries no title key, so a
// gap here would render "event.severity.warning: DNS" to the user.
func TestEventTitleKeysTranslatedInEveryLanguage(t *testing.T) {
b := shippedBundle(t)
keys := []string{
"event.title",
"event.severity.info", "event.severity.warning",
"event.severity.error", "event.severity.critical",
"event.category.network", "event.category.dns",
"event.category.authentication", "event.category.connectivity",
"event.category.system",
}
for _, lang := range b.Languages() {
bundle, err := b.BundleFor(lang.Code)
require.NoError(t, err, "BundleFor(%q)", lang.Code)
for _, key := range keys {
text, ok := bundle[key]
if !assert.True(t, ok, "locale %q is missing key %q", lang.Code, key) {
continue
}
assert.NotEmpty(t, text, "locale %q has an empty message for %q", lang.Code, key)
}
}
// The composed title is useless without both slots.
title, ok := b.Lookup(DefaultLanguage, "event.title", "severity", "Warning", "category", "DNS")
require.True(t, ok)
assert.Equal(t, "Warning: DNS", title, "event.title must substitute both placeholders")
}
func TestBundleLookupReportsMisses(t *testing.T) {
b, err := NewBundle(fakeLocales())
require.NoError(t, err)
got, ok := b.Lookup("en", "tray.menu.connect")
assert.True(t, ok)
assert.Equal(t, "Connect", got)
// An absent key must report a miss rather than echo the key, so callers can
// substitute their own fallback.
got, ok = b.Lookup("en", "tray.missing")
assert.False(t, ok, "unknown key must report a miss")
assert.Empty(t, got, "a miss must not return the key")
// Empty keys reach Lookup from events that carry no title key at all.
_, ok = b.Lookup("en", "")
assert.False(t, ok, "empty key must report a miss")
}

View File

@@ -179,69 +179,6 @@
"notify.mdm.policyApplied.body": {
"message": "Ihre NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Info"
},
"event.severity.warning": {
"message": "Warnung"
},
"event.severity.error": {
"message": "Fehler"
},
"event.severity.critical": {
"message": "Kritisch"
},
"event.category.network": {
"message": "Netzwerk"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Authentifizierung"
},
"event.category.connectivity": {
"message": "Konnektivität"
},
"event.category.system": {
"message": "System"
},
"event.panic": {
"message": "Der NetBird-Dienst ist abgestürzt. Bitte starten Sie den Dienst neu und senden Sie einen Fehlerbericht mit den Client-Protokollen."
},
"event.dns.recovered": {
"message": "DNS-Server sind wieder erreichbar."
},
"event.dns.unreachable": {
"message": "Ein oder mehrere DNS-Server sind nicht erreichbar. Das kann die Verbindung zu einigen Diensten beeinträchtigen."
},
"event.exitNode.connected": {
"message": "Exit Node verbunden."
},
"event.exitNode.disconnected": {
"message": "Exit Node getrennt."
},
"event.exitNode.connectionLost": {
"message": "Verbindung zum Exit Node verloren. Ihr Internetzugang kann beeinträchtigt sein."
},
"event.exitNode.haChange": {
"message": "Exit Node aufgrund einer Änderung der Hochverfügbarkeit getrennt."
},
"event.exitNode.disconnectedUnknown": {
"message": "Exit Node aus unbekannten Gründen getrennt."
},
"event.update.installing": {
"message": "Update wird jetzt installiert."
},
"event.update.completed": {
"message": "Ihr NetBird-Client wurde automatisch auf Version {version} aktualisiert."
},
"event.update.failed": {
"message": "Automatisches Update fehlgeschlagen: {reason}"
},
"common.cancel": {
"message": "Abbrechen"
},

View File

@@ -239,90 +239,6 @@
"message": "Your NetBird configuration was updated by your IT policy.",
"description": "Body of the MDM policy-applied notification, telling the user their settings were changed by their organization's device-management policy."
},
"event.title": {
"message": "{severity}: {category}",
"description": "Notification title for a daemon event, composed from severity and category, e.g. \"Warning: DNS\". Keep both placeholders; use your locale's colon spacing."
},
"event.severity.info": {
"message": "Info",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.severity.warning": {
"message": "Warning",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.severity.error": {
"message": "Error",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.severity.critical": {
"message": "Critical",
"description": "Severity label used in the {severity} slot of event.title. Keep it short."
},
"event.category.network": {
"message": "Network",
"description": "Event category label used in the {category} slot of event.title. Refers to the overlay network."
},
"event.category.dns": {
"message": "DNS",
"description": "Event category label used in the {category} slot of event.title. Acronym, do not translate."
},
"event.category.authentication": {
"message": "Authentication",
"description": "Event category label used in the {category} slot of event.title. Refers to signing in to the management server."
},
"event.category.connectivity": {
"message": "Connectivity",
"description": "Event category label used in the {category} slot of event.title. Refers to reaching peers."
},
"event.category.system": {
"message": "System",
"description": "Event category label used in the {category} slot of event.title. Refers to the local machine and the NetBird service."
},
"event.panic": {
"message": "The NetBird service panicked. Please restart the service and submit a bug report with the client logs.",
"description": "Notification body after the NetBird background service crashed. \"Service\" is the daemon, not a remote service."
},
"event.dns.recovered": {
"message": "DNS servers are reachable again.",
"description": "Notification body when previously unreachable upstream DNS servers respond again."
},
"event.dns.unreachable": {
"message": "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
"description": "Notification body when one or more upstream DNS servers stop responding."
},
"event.exitNode.connected": {
"message": "Exit node connected.",
"description": "Notification body when a full-tunnel exit node becomes active."
},
"event.exitNode.disconnected": {
"message": "Exit node disconnected.",
"description": "Notification body when the user or the client shuts the exit node down deliberately."
},
"event.exitNode.connectionLost": {
"message": "Exit node connection lost. Your internet access might be affected.",
"description": "Notification body when the exit node peer became unreachable. \"Internet access\" means the user's own browsing."
},
"event.exitNode.haChange": {
"message": "Exit node disconnected due to high availability change.",
"description": "Notification body when a high-availability group switched away from this exit node. High availability is the standard IT term."
},
"event.exitNode.disconnectedUnknown": {
"message": "Exit node disconnected for unknown reasons.",
"description": "Notification body when the exit node dropped for a reason the client could not classify."
},
"event.update.installing": {
"message": "Installing update now.",
"description": "Notification body shown as an automatic client update starts installing."
},
"event.update.completed": {
"message": "Your NetBird client was auto-updated to version {version}.",
"description": "Notification body after an automatic client update succeeded. {version} is a version number, keep verbatim."
},
"event.update.failed": {
"message": "Auto-update failed: {reason}",
"description": "Notification body when an automatic client update failed. {reason} is an untranslated technical error string; keep your locale's colon spacing."
},
"common.cancel": {
"message": "Cancel",
"description": "Generic Cancel button label, reused across dialogs. Keep short."

View File

@@ -179,69 +179,6 @@
"notify.mdm.policyApplied.body": {
"message": "Su configuración de NetBird fue actualizada por su política de TI."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Información"
},
"event.severity.warning": {
"message": "Advertencia"
},
"event.severity.error": {
"message": "Error"
},
"event.severity.critical": {
"message": "Crítico"
},
"event.category.network": {
"message": "Red"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Autenticación"
},
"event.category.connectivity": {
"message": "Conectividad"
},
"event.category.system": {
"message": "Sistema"
},
"event.panic": {
"message": "El servicio de NetBird falló de forma inesperada. Reinicie el servicio y envíe un informe de error con los registros del cliente."
},
"event.dns.recovered": {
"message": "Los servidores DNS vuelven a estar accesibles."
},
"event.dns.unreachable": {
"message": "No se puede acceder a uno o más servidores DNS. Esto puede afectar la conexión a algunos servicios."
},
"event.exitNode.connected": {
"message": "Nodo de salida conectado."
},
"event.exitNode.disconnected": {
"message": "Nodo de salida desconectado."
},
"event.exitNode.connectionLost": {
"message": "Se perdió la conexión con el nodo de salida. Su acceso a Internet puede verse afectado."
},
"event.exitNode.haChange": {
"message": "Nodo de salida desconectado por un cambio de alta disponibilidad."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nodo de salida desconectado por motivos desconocidos."
},
"event.update.installing": {
"message": "Instalando la actualización ahora."
},
"event.update.completed": {
"message": "Su cliente de NetBird se actualizó automáticamente a la versión {version}."
},
"event.update.failed": {
"message": "La actualización automática falló: {reason}"
},
"common.cancel": {
"message": "Cancelar"
},

View File

@@ -179,69 +179,6 @@
"notify.mdm.policyApplied.body": {
"message": "Votre configuration NetBird a été mise à jour par votre politique informatique."
},
"event.title": {
"message": "{severity} : {category}"
},
"event.severity.info": {
"message": "Info"
},
"event.severity.warning": {
"message": "Avertissement"
},
"event.severity.error": {
"message": "Erreur"
},
"event.severity.critical": {
"message": "Critique"
},
"event.category.network": {
"message": "Réseau"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Authentification"
},
"event.category.connectivity": {
"message": "Connectivité"
},
"event.category.system": {
"message": "Système"
},
"event.panic": {
"message": "Le service NetBird s'est arrêté brutalement. Veuillez redémarrer le service et envoyer un rapport de bug avec les journaux du client."
},
"event.dns.recovered": {
"message": "Les serveurs DNS sont de nouveau joignables."
},
"event.dns.unreachable": {
"message": "Impossible de joindre un ou plusieurs serveurs DNS. Cela peut affecter la connexion à certains services."
},
"event.exitNode.connected": {
"message": "Nœud de sortie connecté."
},
"event.exitNode.disconnected": {
"message": "Nœud de sortie déconnecté."
},
"event.exitNode.connectionLost": {
"message": "Connexion au nœud de sortie perdue. Votre accès à Internet peut être affecté."
},
"event.exitNode.haChange": {
"message": "Nœud de sortie déconnecté suite à un changement de haute disponibilité."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nœud de sortie déconnecté pour une raison inconnue."
},
"event.update.installing": {
"message": "Installation de la mise à jour en cours."
},
"event.update.completed": {
"message": "Votre client NetBird a été mis à jour automatiquement vers la version {version}."
},
"event.update.failed": {
"message": "Échec de la mise à jour automatique : {reason}"
},
"common.cancel": {
"message": "Annuler"
},

View File

@@ -179,69 +179,6 @@
"notify.mdm.policyApplied.body": {
"message": "A NetBird konfigurációt az IT-szabályzat frissítette."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Információ"
},
"event.severity.warning": {
"message": "Figyelmeztetés"
},
"event.severity.error": {
"message": "Hiba"
},
"event.severity.critical": {
"message": "Kritikus"
},
"event.category.network": {
"message": "Hálózat"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Hitelesítés"
},
"event.category.connectivity": {
"message": "Kapcsolat"
},
"event.category.system": {
"message": "Rendszer"
},
"event.panic": {
"message": "A NetBird szolgáltatás összeomlott. Kérjük, indítsa újra a szolgáltatást, és küldjön hibajelentést a kliens naplóival."
},
"event.dns.recovered": {
"message": "A DNS-kiszolgálók ismét elérhetők."
},
"event.dns.unreachable": {
"message": "Egy vagy több DNS-kiszolgáló nem érhető el. Ez befolyásolhatja egyes szolgáltatások elérését."
},
"event.exitNode.connected": {
"message": "Exit Node csatlakoztatva."
},
"event.exitNode.disconnected": {
"message": "Exit Node leválasztva."
},
"event.exitNode.connectionLost": {
"message": "Megszakadt a kapcsolat az Exit Node-dal. Ez érintheti az internetelérést."
},
"event.exitNode.haChange": {
"message": "Az Exit Node leválasztva a magas rendelkezésre állás változása miatt."
},
"event.exitNode.disconnectedUnknown": {
"message": "Az Exit Node ismeretlen okból leválasztva."
},
"event.update.installing": {
"message": "A frissítés telepítése folyamatban."
},
"event.update.completed": {
"message": "A NetBird kliens automatikusan a {version} verzióra frissült."
},
"event.update.failed": {
"message": "Az automatikus frissítés sikertelen: {reason}"
},
"common.cancel": {
"message": "Mégse"
},

View File

@@ -179,69 +179,6 @@
"notify.mdm.policyApplied.body": {
"message": "La configurazione di NetBird è stata aggiornata dalla policy IT."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Info"
},
"event.severity.warning": {
"message": "Avviso"
},
"event.severity.error": {
"message": "Errore"
},
"event.severity.critical": {
"message": "Critico"
},
"event.category.network": {
"message": "Rete"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Autenticazione"
},
"event.category.connectivity": {
"message": "Connettività"
},
"event.category.system": {
"message": "Sistema"
},
"event.panic": {
"message": "Il servizio NetBird si è arrestato in modo anomalo. Riavvii il servizio e invii una segnalazione di bug con i log del client."
},
"event.dns.recovered": {
"message": "I server DNS sono di nuovo raggiungibili."
},
"event.dns.unreachable": {
"message": "Impossibile raggiungere uno o più server DNS. Questo potrebbe influire sulla connessione ad alcuni servizi."
},
"event.exitNode.connected": {
"message": "Nodo di uscita connesso."
},
"event.exitNode.disconnected": {
"message": "Nodo di uscita disconnesso."
},
"event.exitNode.connectionLost": {
"message": "Connessione al nodo di uscita perduta. L'accesso a Internet potrebbe essere compromesso."
},
"event.exitNode.haChange": {
"message": "Nodo di uscita disconnesso a causa di una modifica dell'alta disponibilità."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nodo di uscita disconnesso per motivi sconosciuti."
},
"event.update.installing": {
"message": "Installazione dell'aggiornamento in corso."
},
"event.update.completed": {
"message": "Il client NetBird è stato aggiornato automaticamente alla versione {version}."
},
"event.update.failed": {
"message": "Aggiornamento automatico non riuscito: {reason}"
},
"common.cancel": {
"message": "Annulla"
},

View File

@@ -179,69 +179,6 @@
"notify.mdm.policyApplied.body": {
"message": "NetBird の構成が IT ポリシーによって更新されました。"
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "情報"
},
"event.severity.warning": {
"message": "警告"
},
"event.severity.error": {
"message": "エラー"
},
"event.severity.critical": {
"message": "重大"
},
"event.category.network": {
"message": "ネットワーク"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "認証"
},
"event.category.connectivity": {
"message": "接続"
},
"event.category.system": {
"message": "システム"
},
"event.panic": {
"message": "NetBird サービスがクラッシュしました。サービスを再起動し、クライアントログを添えてバグを報告してください。"
},
"event.dns.recovered": {
"message": "DNS サーバーに再び到達できるようになりました。"
},
"event.dns.unreachable": {
"message": "1 つ以上の DNS サーバーに到達できません。一部のサービスへの接続に影響する可能性があります。"
},
"event.exitNode.connected": {
"message": "出口ノードに接続しました。"
},
"event.exitNode.disconnected": {
"message": "出口ノードの接続を解除しました。"
},
"event.exitNode.connectionLost": {
"message": "出口ノードとの接続が失われました。インターネット接続に影響する可能性があります。"
},
"event.exitNode.haChange": {
"message": "高可用性の変更により出口ノードの接続が解除されました。"
},
"event.exitNode.disconnectedUnknown": {
"message": "不明な理由により出口ノードの接続が解除されました。"
},
"event.update.installing": {
"message": "更新をインストールしています。"
},
"event.update.completed": {
"message": "NetBird クライアントがバージョン {version} に自動更新されました。"
},
"event.update.failed": {
"message": "自動更新に失敗しました: {reason}"
},
"common.cancel": {
"message": "キャンセル"
},

View File

@@ -179,69 +179,6 @@
"notify.mdm.policyApplied.body": {
"message": "A sua configuração do NetBird foi atualizada pela política de TI."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Informação"
},
"event.severity.warning": {
"message": "Aviso"
},
"event.severity.error": {
"message": "Erro"
},
"event.severity.critical": {
"message": "Crítico"
},
"event.category.network": {
"message": "Rede"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Autenticação"
},
"event.category.connectivity": {
"message": "Conectividade"
},
"event.category.system": {
"message": "Sistema"
},
"event.panic": {
"message": "O serviço NetBird falhou de forma inesperada. Reinicie o serviço e envie um relatório de erro com os registros do cliente."
},
"event.dns.recovered": {
"message": "Os servidores DNS estão novamente acessíveis."
},
"event.dns.unreachable": {
"message": "Não é possível acessar um ou mais servidores DNS. Isto pode afetar a conexão a alguns serviços."
},
"event.exitNode.connected": {
"message": "Nó de saída conectado."
},
"event.exitNode.disconnected": {
"message": "Nó de saída desconectado."
},
"event.exitNode.connectionLost": {
"message": "Conexão com o nó de saída perdida. O seu acesso à Internet pode ser afetado."
},
"event.exitNode.haChange": {
"message": "Nó de saída desconectado devido a uma alteração de alta disponibilidade."
},
"event.exitNode.disconnectedUnknown": {
"message": "Nó de saída desconectado por motivos desconhecidos."
},
"event.update.installing": {
"message": "Instalando a atualização agora."
},
"event.update.completed": {
"message": "O seu cliente NetBird foi atualizado automaticamente para a versão {version}."
},
"event.update.failed": {
"message": "Falha na atualização automática: {reason}"
},
"common.cancel": {
"message": "Cancelar"
},

View File

@@ -179,69 +179,6 @@
"notify.mdm.policyApplied.body": {
"message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой."
},
"event.title": {
"message": "{severity}: {category}"
},
"event.severity.info": {
"message": "Информация"
},
"event.severity.warning": {
"message": "Предупреждение"
},
"event.severity.error": {
"message": "Ошибка"
},
"event.severity.critical": {
"message": "Критично"
},
"event.category.network": {
"message": "Сеть"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "Аутентификация"
},
"event.category.connectivity": {
"message": "Связь"
},
"event.category.system": {
"message": "Система"
},
"event.panic": {
"message": "Служба NetBird аварийно завершилась. Перезапустите службу и отправьте отчёт об ошибке с журналами клиента."
},
"event.dns.recovered": {
"message": "DNS-серверы снова доступны."
},
"event.dns.unreachable": {
"message": "Не удалось связаться с одним или несколькими DNS-серверами. Это может повлиять на подключение к некоторым сервисам."
},
"event.exitNode.connected": {
"message": "Выходной узел подключён."
},
"event.exitNode.disconnected": {
"message": "Выходной узел отключён."
},
"event.exitNode.connectionLost": {
"message": "Соединение с выходным узлом потеряно. Доступ в интернет может быть нарушен."
},
"event.exitNode.haChange": {
"message": "Выходной узел отключён из-за изменения конфигурации высокой доступности."
},
"event.exitNode.disconnectedUnknown": {
"message": "Выходной узел отключён по неизвестной причине."
},
"event.update.installing": {
"message": "Устанавливается обновление."
},
"event.update.completed": {
"message": "Клиент NetBird автоматически обновлён до версии {version}."
},
"event.update.failed": {
"message": "Не удалось выполнить автоматическое обновление: {reason}"
},
"common.cancel": {
"message": "Отмена"
},

View File

@@ -179,69 +179,6 @@
"notify.mdm.policyApplied.body": {
"message": "您的 NetBird 配置已根据 IT 策略更新。"
},
"event.title": {
"message": "{severity}{category}"
},
"event.severity.info": {
"message": "信息"
},
"event.severity.warning": {
"message": "警告"
},
"event.severity.error": {
"message": "错误"
},
"event.severity.critical": {
"message": "严重"
},
"event.category.network": {
"message": "网络"
},
"event.category.dns": {
"message": "DNS"
},
"event.category.authentication": {
"message": "身份验证"
},
"event.category.connectivity": {
"message": "连接"
},
"event.category.system": {
"message": "系统"
},
"event.panic": {
"message": "NetBird 服务发生崩溃。请重启该服务,并附上客户端日志提交错误报告。"
},
"event.dns.recovered": {
"message": "DNS 服务器已恢复可访问。"
},
"event.dns.unreachable": {
"message": "无法访问一个或多个 DNS 服务器。这可能影响您连接部分服务。"
},
"event.exitNode.connected": {
"message": "出口节点已连接。"
},
"event.exitNode.disconnected": {
"message": "出口节点已断开。"
},
"event.exitNode.connectionLost": {
"message": "与出口节点的连接已丢失。您的互联网访问可能受到影响。"
},
"event.exitNode.haChange": {
"message": "由于高可用性变更,出口节点已断开。"
},
"event.exitNode.disconnectedUnknown": {
"message": "出口节点因未知原因已断开。"
},
"event.update.installing": {
"message": "正在安装更新。"
},
"event.update.completed": {
"message": "NetBird 客户端已自动更新到版本 {version}。"
},
"event.update.failed": {
"message": "自动更新失败:{reason}"
},
"common.cancel": {
"message": "取消"
},

View File

@@ -63,20 +63,6 @@ func (l *Localizer) T(key string, args ...string) string {
return l.bundle.Translate(lang, key, args...)
}
// Lookup resolves a key supplied at runtime by the daemon, substituting args as
// {placeholder}/value pairs. It reports false when the key is in no bundle, so
// the caller can fall back to the daemon's own English text instead of showing a
// bare key. An empty key never resolves.
func (l *Localizer) Lookup(key string, args map[string]string) (string, bool) {
if l == nil || l.bundle == nil || key == "" {
return "", false
}
l.mu.RLock()
lang := l.lang
l.mu.RUnlock()
return l.bundle.Lookup(lang, key, flattenArgs(args)...)
}
// Watch invokes cb on each language change, after the cached language is
// updated so cb may call l.T with the new locale. Replaces any prior subscription.
func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) {
@@ -142,17 +128,3 @@ func (l *Localizer) StatusLabel(status string) string {
}
return status
}
// flattenArgs turns a placeholder map into the flat name/value slice the bundle
// takes. Iteration order is irrelevant: each pair substitutes an independent
// {name}.
func flattenArgs(args map[string]string) []string {
if len(args) == 0 {
return nil
}
out := make([]string, 0, len(args)*2)
for name, value := range args {
out = append(out, name, value)
}
return out
}

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

@@ -59,21 +59,13 @@ type Emitter interface {
// SystemEvent is the frontend-facing shape of a daemon SystemEvent.
type SystemEvent struct {
ID string `json:"id"`
Severity string `json:"severity"`
Category string `json:"category"`
Message string `json:"message"`
UserMessage string `json:"userMessage"`
// MessageKey names the localizable body for this event; empty on control
// events and on events from a daemon that predates the field. Resolve it
// against the UI bundle and fall back to UserMessage on a miss.
MessageKey string `json:"messageKey"`
MessageArgs map[string]string `json:"messageArgs"`
// TitleKey names the localizable notification title, empty when the event
// has none and the consumer should compose one from severity and category.
TitleKey string `json:"titleKey"`
Timestamp int64 `json:"timestamp"`
Metadata map[string]string `json:"metadata"`
ID string `json:"id"`
Severity string `json:"severity"`
Category string `json:"category"`
Message string `json:"message"`
UserMessage string `json:"userMessage"`
Timestamp int64 `json:"timestamp"`
Metadata map[string]string `json:"metadata"`
}
// PeerStatus is the frontend-facing shape of a daemon PeerState.
@@ -571,9 +563,6 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
Category: strings.ToLower(strings.TrimPrefix(e.GetCategory().String(), "SystemEvent_")),
Message: e.GetMessage(),
UserMessage: e.GetUserMessage(),
MessageKey: e.GetMessageKey(),
MessageArgs: e.GetMessageArgs(),
TitleKey: e.GetTitleKey(),
Metadata: map[string]string{},
}
if ts := e.GetTimestamp(); ts != 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

@@ -26,6 +26,7 @@ const (
notifyIDUpdatePrefix = "netbird-update-"
notifyIDEvent = "netbird-event-"
notifyIDTrayError = "netbird-tray-error"
notifyIDMDMPolicy = "netbird-mdm-policy"
statusError = "Error"
@@ -43,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

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

View File

@@ -21,14 +21,32 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
if !ok {
return
}
// config_changed carries no user-facing message, so handle it before the gate below.
// config_changed carries no UserMessage, so handle it before the message gate below.
if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged {
log.Infof("config_changed event received (source=%s); refreshing tray restrictions", se.Metadata[proto.MetadataSourceKey])
go t.refreshRestrictions()
go t.loadConfig()
// MDM gets a localised toast here; the daemon's English "policy_applied"
// event is suppressed in shouldSkipSystemEvent. Other sources stay silent.
if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM {
t.profileMu.Lock()
enabled := t.notificationsEnabled
t.profileMu.Unlock()
if enabled {
t.notify(
t.loc.T("notify.mdm.policyApplied.title"),
t.loc.T("notify.mdm.policyApplied.body"),
notifyIDMDMPolicy,
)
}
}
return
}
if se.MessageKey == "" && se.UserMessage == "" {
// Session-warning and deadline-rejected events build their body locally from
// metadata; every other event needs a UserMessage.
isSessionWarning := se.Metadata[authsession.MetaWarning] == "true"
isDeadlineRejected := se.Metadata[authsession.MetaDeadlineRejected] != ""
if !isSessionWarning && !isDeadlineRejected && se.UserMessage == "" {
return
}
if shouldSkipSystemEvent(se) {
@@ -43,56 +61,56 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
return
}
body := t.localizedEventMessage(se)
// Session-warning events route via stable metadata flags rather than
// category/severity so a daemon-side reword still lands here. Final warning
// auto-opens the SessionExpiration dialog with no notification (the dialog is
// the last-chance reminder; doubling up would be noise).
if isDeadlineRejected {
t.notify(
t.loc.T("notify.sessionDeadlineRejected.title"),
t.loc.T("notify.sessionDeadlineRejected.body"),
notifyIDSessionExpired,
)
return
}
// The final session warning auto-opens the SessionExpiration dialog instead of
// toasting: the dialog is the last-chance reminder and doubling up would be
// noise. This routes on metadata rather than the message key because it is a
// behavioural distinction, not a wording one.
if se.Metadata[authsession.MetaWarning] == "true" {
if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" {
if se.Metadata[authsession.MetaFinal] == "true" {
t.openSessionExpiration()
return
}
t.notifySessionWarning(t.eventTitle(se), body)
t.notifySessionWarning(
t.loc.T("notify.sessionWarning.title"),
t.buildSessionWarningBody(se.Metadata),
)
return
}
body := se.UserMessage
if id := se.Metadata["id"]; id != "" {
body += fmt.Sprintf(" ID: %s", id)
}
t.notify(t.eventTitle(se), body, notifyIDEvent+se.ID)
t.notify(eventTitle(se), body, notifyIDEvent+se.ID)
}
// localizedEventMessage resolves the daemon's message key against the active
// locale. A key this build does not ship — a daemon newer than the UI — falls
// back to the daemon's own English rendering rather than showing a bare key.
func (t *Tray) localizedEventMessage(se services.SystemEvent) string {
if body, ok := t.loc.Lookup(se.MessageKey, se.MessageArgs); ok {
return body
// eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication".
func eventTitle(e services.SystemEvent) string {
prefix := titleCase(e.Severity)
if prefix == "" {
prefix = "Info"
}
if se.MessageKey != "" {
log.Debugf("no translation for event message key %q, using the daemon's text", se.MessageKey)
category := titleCase(e.Category)
if category == "" {
category = "System"
}
return se.UserMessage
return prefix + ": " + category
}
// eventTitle resolves the event's own title key, falling back to a title
// composed from severity and category, e.g. "Critical: DNS" in English. An enum
// value this build does not know falls back to the Info and System labels.
func (t *Tray) eventTitle(se services.SystemEvent) string {
if title, ok := t.loc.Lookup(se.TitleKey, nil); ok {
return title
func titleCase(s string) string {
if s == "" {
return ""
}
severity, ok := t.loc.Lookup("event.severity."+strings.ToLower(se.Severity), nil)
if !ok {
severity = t.loc.T("event.severity.info")
}
category, ok := t.loc.Lookup("event.category."+strings.ToLower(se.Category), nil)
if !ok {
category = t.loc.T("event.category.system")
}
return t.loc.T("event.title", "severity", severity, "category", category)
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
}
// shouldSkipSystemEvent reports whether a daemon SystemEvent must not surface as
@@ -101,6 +119,11 @@ func (t *Tray) eventTitle(se services.SystemEvent) string {
// - install-progress signals (consumed by the install-progress window)
// - the ::/0 partner of an exit-node default route (0.0.0.0/0 already toasted)
func shouldSkipSystemEvent(se services.SystemEvent) bool {
// "policy_applied" carries a hardcoded English message; the localised toast
// fires on the paired config_changed (source=mdm) event instead.
if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied {
return true
}
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
return true
}

View File

@@ -1,150 +0,0 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/i18n"
"github.com/netbirdio/netbird/client/ui/services"
)
// trayWithLocalizer builds the minimum Tray the message/title resolvers touch:
// they read t.loc and nothing else, so no app, window or daemon connection is
// needed. The shipped locale tree is used so the assertions below exercise the
// real bundles rather than a fixture.
func trayWithLocalizer(t *testing.T) *Tray {
t.Helper()
bundle, err := i18n.NewBundle(os.DirFS("i18n/locales"))
require.NoError(t, err, "the shipped locale tree must load")
return &Tray{loc: NewLocalizer(bundle, nil)}
}
func TestLocalizedEventMessageResolvesKey(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{
MessageKey: string(proto.UserMsgExitNodeConnected),
// A daemon always ships its English rendering too; the key must win.
UserMessage: "should not be used",
})
assert.Equal(t, "Exit node connected.", got)
}
func TestLocalizedEventMessageSubstitutesArgs(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{
MessageKey: string(proto.UserMsgUpdateCompleted),
MessageArgs: map[string]string{proto.ArgVersion: "0.60.1"},
})
assert.Equal(t, "Your NetBird client was auto-updated to version 0.60.1.", got)
}
// A daemon newer than the UI can publish a key this build has never heard of.
// Showing the raw key would be a visible regression, so the daemon's own English
// text has to win instead.
func TestLocalizedEventMessageFallsBackToDaemonText(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{
MessageKey: "event.somethingThisBuildNeverHeardOf",
UserMessage: "A message from a newer daemon.",
})
assert.Equal(t, "A message from a newer daemon.", got)
}
// An old daemon sends no key at all, only userMessage.
func TestLocalizedEventMessageWithoutKey(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.localizedEventMessage(services.SystemEvent{UserMessage: "Legacy English text."})
assert.Equal(t, "Legacy English text.", got)
}
func TestEventTitlePrefersTitleKey(t *testing.T) {
tray := trayWithLocalizer(t)
got := tray.eventTitle(services.SystemEvent{
Severity: "critical",
Category: "authentication",
TitleKey: string(proto.TitleSessionWarning),
})
assert.Equal(t, "Session expires soon", got, "a title key must beat the composed title")
}
func TestEventTitleComposesFromSeverityAndCategory(t *testing.T) {
tray := trayWithLocalizer(t)
tests := []struct {
name string
severity string
category string
want string
}{
{"warning dns", "warning", "dns", "Warning: DNS"},
{"critical system", "critical", "system", "Critical: System"},
{"info network", "info", "network", "Info: Network"},
{"error authentication", "error", "authentication", "Error: Authentication"},
// Enum values this build does not know, and the empty severity/category
// an event carries before the daemon fills them in.
{"unknown severity", "apocalyptic", "dns", "Info: DNS"},
{"unknown category", "warning", "quantum", "Warning: System"},
{"empty", "", "", "Info: System"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tray.eventTitle(services.SystemEvent{Severity: tc.severity, Category: tc.category})
assert.Equal(t, tc.want, got)
})
}
}
func TestShouldSkipSystemEvent(t *testing.T) {
tests := []struct {
name string
ev services.SystemEvent
want bool
}{
{
name: "update announcement handled by the tray updater",
ev: services.SystemEvent{Metadata: map[string]string{"new_version_available": "0.60.1"}},
want: true,
},
{
name: "install progress belongs to the progress window",
ev: services.SystemEvent{Metadata: map[string]string{"progress_window": "show"}},
want: true,
},
{
name: "the v6 half of a dual-stack default route is already toasted as v4",
ev: services.SystemEvent{Category: "network", Metadata: map[string]string{"network": "::/0"}},
want: true,
},
{
name: "the v4 default route is the one that toasts",
ev: services.SystemEvent{Category: "network", Metadata: map[string]string{"network": "0.0.0.0/0"}},
want: false,
},
{
// policy_applied used to be suppressed here while the tray toasted
// off the paired config_changed event; it now carries its own keys.
name: "mdm policy applied surfaces normally",
ev: services.SystemEvent{
MessageKey: string(proto.UserMsgMDMPolicyApplied),
Metadata: map[string]string{proto.MetadataTypeKey: proto.MetadataTypePolicyApplied},
},
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, shouldSkipSystemEvent(tc.ev))
})
}
}

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

@@ -10,6 +10,8 @@ import (
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
nbstatus "github.com/netbirdio/netbird/client/status"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/services"
)
@@ -25,11 +27,10 @@ const (
finalWarningCountdownSeconds = 120
)
// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal.
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
func (t *Tray) handleSessionExpired() {
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
}
@@ -194,6 +195,25 @@ func (t *Tray) registerSessionWarningCategory() {
})
}
// buildSessionWarningBody composes the localised notification body from the daemon's metadata.
// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence.
// Falls back to a generic string when metadata is missing or unparsable.
func (t *Tray) buildSessionWarningBody(meta map[string]string) string {
if meta == nil {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
raw := meta[authsession.MetaExpiresAt]
if raw == "" {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
deadline, err := authsession.ParseExpiresAt(raw)
if err != nil {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
remaining := nbstatus.FormatRemainingDuration(time.Until(deadline))
return t.loc.T("notify.sessionWarning.body", "remaining", remaining)
}
// notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the
// with-actions variant is unavailable (older platform impls, or a bare Notifier in tests).
func (t *Tray) notifySessionWarning(title, body string) {
@@ -287,11 +307,7 @@ func (t *Tray) openSessionExtendFlow() {
}
seconds := int(time.Until(deadline).Seconds())
if seconds <= 0 {
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
}
t.app.Event.Emit(services.EventTriggerLogin)
return
}
if t.svc.WindowManager == nil {

View File

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

@@ -160,8 +160,19 @@ func TestSettingsRoundTrip(t *testing.T) {
assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates")
assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates")
// A cluster different from the pinned one must be rejected; echoing the
// pinned one back is valid.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr("attacker.cluster.invalid"),
EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii,
})
requireClientError(t, err)
// Restore the original toggles.
_, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr(before.Cluster),
EnableLogCollection: before.EnableLogCollection,
EnablePromptCollection: before.EnablePromptCollection,
RedactPii: before.RedactPii,

View File

@@ -0,0 +1,114 @@
//go:build e2e
package agentnetwork
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// harnessStartFresh boots a dedicated combined server with its own fresh
// account and registers its teardown on t.
func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) {
t.Helper()
fresh, err := harness.StartCombined(ctx)
if err != nil {
return nil, err
}
t.Cleanup(func() { _ = fresh.Terminate(context.Background()) })
if _, err := fresh.Bootstrap(ctx); err != nil {
return nil, err
}
return fresh, nil
}
// TestSettingsBootstrapViaPut covers the settings-first bootstrap path on an
// account that has never been bootstrapped: the GET reads as the defaults
// with an empty cluster/subdomain/endpoint, a PUT without a cluster has
// nothing to pin and fails, and a PUT carrying a cluster creates the row and
// pins it immutably. The shared srv cannot provide that starting state (any
// provider-creating test bootstraps it, and test order is deliberately not
// relied on), so this boots a dedicated combined server — the image is
// already built and cached by TestMain's StartCombined, so the extra cost is
// one container start.
func TestSettingsBootstrapViaPut(t *testing.T) {
ctx := context.Background()
fresh, err := harnessStartFresh(ctx, t)
require.NoError(t, err, "start dedicated combined server")
// Before agent-network bootstrap the settings read as the defaults, not
// as an error and not as a null body.
before, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings on a fresh account must succeed")
assert.Empty(t, before.Cluster, "cluster must be empty before bootstrap")
assert.Empty(t, before.Subdomain, "subdomain must be empty before bootstrap")
assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap, not a bare dot")
assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off")
// A PUT without a cluster has nothing to pin the account to.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
EnableLogCollection: true,
})
requireClientError(t, err)
// A PUT carrying a cluster bootstraps the account and applies the
// mutable fields from the same request. Every toggle is set away from
// its bootstrap default so each assertion can actually fail.
const cluster = "e2e.bootstrap.netbird.selfhosted"
bootstrapped, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr(cluster),
EnableLogCollection: false,
EnablePromptCollection: true,
RedactPii: true,
})
require.NoError(t, err, "bootstrap settings via PUT must succeed")
assert.Equal(t, cluster, bootstrapped.Cluster, "cluster must be pinned from the request")
require.NotEmpty(t, bootstrapped.Subdomain, "subdomain must be assigned at bootstrap")
assert.Equal(t, bootstrapped.Subdomain+"."+cluster, bootstrapped.Endpoint, "endpoint must combine subdomain and cluster")
assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default")
assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply")
assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply")
// The row is persisted: an independent read agrees on every field.
after, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after bootstrap must succeed")
assert.Equal(t, bootstrapped.Endpoint, after.Endpoint, "bootstrap must persist across reads")
assert.Equal(t, bootstrapped.EnableLogCollection, after.EnableLogCollection, "log collection must persist")
assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist")
assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist")
// Once bootstrapped, later updates may omit the cluster entirely.
persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
EnableLogCollection: true,
EnablePromptCollection: false,
RedactPii: true,
})
require.NoError(t, err, "post-bootstrap update without cluster must succeed")
assert.Equal(t, cluster, persisted.Cluster, "omitted cluster must keep the pinned value")
assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply")
assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply")
// The cluster is immutable: a different value is rejected rather than
// silently ignored, and the rejected update must not disturb anything.
_, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{
Cluster: ptr("other.cluster.invalid"),
EnableLogCollection: false,
})
requireClientError(t, err)
final, err := fresh.GetSettings(ctx)
require.NoError(t, err, "get settings after the rejected cluster change must succeed")
assert.Equal(t, persisted.Cluster, final.Cluster, "rejected update must not change the cluster")
assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected update must not change the endpoint")
assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected update must not apply its toggles")
assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected update must not apply its toggles")
}

6
go.mod
View File

@@ -81,7 +81,7 @@ require (
github.com/miekg/dns v1.1.72
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/moby/moby/api v1.54.1
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42
github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45
github.com/oapi-codegen/runtime v1.1.2
github.com/okta/okta-sdk-golang/v2 v2.18.0
@@ -114,7 +114,7 @@ require (
github.com/ti-mo/conntrack v0.5.1
github.com/ti-mo/netfilter v0.5.2
github.com/vmihailenco/msgpack/v5 v5.4.1
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
github.com/wailsapp/wails/v3 v3.0.0-beta.3
github.com/yusufpapurcu/wmi v1.2.4
github.com/zcalusic/sysinfo v1.1.3
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
@@ -339,3 +339,5 @@ replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205
replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4

8
go.sum
View File

@@ -484,12 +484,14 @@ github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6
github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI=
github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 h1:iJeUvSMC0BTpkw7u4JyWcY4/3dl7fEL9DR/TpKf2+1w=
github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87/go.mod h1:pmsCPx1S0nuZRxCextGpc9AV4hLgGSuTsc4NMuwGeCo=
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8=
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4 h1:UKztc3QjWvzU5DZk+uYaOWN0x62NSe/pkxuPvzqZIy4=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -660,8 +662,6 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=

View File

@@ -0,0 +1,57 @@
package networkmap_pgsql
import (
"context"
"testing"
"time"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetAccountSettings(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into accounts (id, settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled)
values('account-3',null,null,null,null,null,null,null,null,null,null,null)`)
accountSettings, err := networkmap_pgsql.GetAccountSettingsViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{
PeerLoginExpirationEnabled: true,
PeerLoginExpiration: 86400000000000 * time.Nanosecond,
PeerInactivityExpirationEnabled: false,
PeerInactivityExpiration: 86400000000000 * time.Nanosecond,
DNSDomain: "",
IPv6EnabledGroups: []string{"group-one-resource-id"},
RoutingPeerDNSResolutionEnabled: false,
LazyConnectionEnabled: false,
AutoUpdateVersion: "disabled",
AutoUpdateAlways: false,
MetricsPushEnabled: false,
})
accountSettings, err = networkmap_pgsql.GetAccountSettingsViaPgxConnection(ctx, conn(t, ctx), "account-2")
assert.NoError(t, err)
assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{
PeerLoginExpirationEnabled: true,
PeerLoginExpiration: 86400000000000 * time.Nanosecond,
PeerInactivityExpirationEnabled: false,
PeerInactivityExpiration: 86400000000000 * time.Nanosecond,
DNSDomain: "",
IPv6EnabledGroups: []string{"group-two-resources-id"},
RoutingPeerDNSResolutionEnabled: false,
LazyConnectionEnabled: false,
AutoUpdateVersion: "disabled",
AutoUpdateAlways: false,
MetricsPushEnabled: false,
})
accountSettings, err = networkmap_pgsql.GetAccountSettingsViaPgxConnection(ctx, conn(t, ctx), "account-3")
assert.NoError(t, err)
assert.Equal(t, accountSettings, nmdata.AccountSettingsInfo{})
}

View File

@@ -0,0 +1,52 @@
insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups,
settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled)
VALUES('account-1','network-1','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]',
true, 86400000000000, false,
86400000000000, null, '["group-one-resource-id"]', false,
false, 'disabled', false, false);
insert into accounts (id, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups,
settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled)
VALUES('account-2','network-2','{"IP":"110.0.0.0","Mask":"//8AAA=="}','{"IP":"fddf:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',2,null,
true, 86400000000000, false,
86400000000000, null, '["group-two-resources-id"]', false,
false, 'disabled', false, false);
insert into groups (id, account_id, name, resources, public_id) VALUES('group-one-resource-id','account-1','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public');
insert into groups (id, account_id, name, resources, public_id) VALUES('group-two-resources-id','account-1','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','group-two-resources-id-public');
insert into groups (id, account_id, name, resources, public_id) VALUES('group-no-resources-id','account-1','group-3-name', null,'group-no-resources-id-public');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-1','group-one-resource-id');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-2','group-two-resources-id');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-3','group-two-resources-id');
insert into peers (id, account_id, "key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-1','account-1','key-1','ssh-key-1','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999+00','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"',
false,true,true,'cluster-1.netbird.services',
'0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'DE','Berlin','"46.201.148.187"');
insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-2','account-1','key-2','ssh-key-2','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999+00','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"',
false,true,true,'cluster-2.netbird.services',
'0.76.1','linux','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0,
'DE','Berlin','"46.201.149.187"');
insert into peers (id,account_id,"key", ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-3','account-1','key-3','ssh-key-3','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999+00','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"',
false,true,true,'cluster-3.netbird.services',
'0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'DE','Berlin','"46.201.150.187"');

View File

@@ -0,0 +1,23 @@
package networkmap_pgsql
import (
"context"
"testing"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetDnsSettings(t *testing.T) {
ctx := context.TODO()
settings, err := pgstore.GetDnsSettings(ctx, "account-1")
assert.NoError(t, err)
assert.Equal(t, settings, nmdata.DNSSettings{
DisabledManagementGroups: []string{"disabled-group-1", "disabled-group-2"},
})
settings, err = pgstore.GetDnsSettings(ctx, "account-2")
assert.NoError(t, err)
assert.Equal(t, settings, nmdata.DNSSettings{})
}

View File

@@ -0,0 +1,61 @@
package networkmap_pgsql
import (
"context"
"testing"
"github.com/miekg/dns"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetAppliedZoneCandidatesViaPgxConnection(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into zones (id, account_id, domain, enable_search_domain, distribution_groups)
VALUES('zone-1','account-1','test-1.com',true,'["group-one-resource-id"]')`)
execQuery(t, ctx,
`insert into zones (id, account_id, domain, enable_search_domain, distribution_groups)
VALUES('zone-2','account-1','test-2.com',false,'["group-two-resources-id"]')`)
execQuery(t, ctx,
`insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-1','account-1','zone-1','test.test-1.com','A',1800,'1.1.1.1')`)
execQuery(t, ctx,
`insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-2','account-1','zone-1','test2.test-1.com','A',1800,'1.1.1.2')`)
execQuery(t, ctx,
`insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-3','account-1','zone-1','test3.test-1.com','CNAME',1800,'test4.test-1.com')`)
execQuery(t, ctx,
`insert into records (id, account_id, zone_id, name, type, ttl, content)
VALUES('record-4','account-1','zone-2','test2.test-2.com','CNAME',1800,'test3.test-2.com')`)
zoneCandidates, err := networkmap_pgsql.GetAppliedZoneCandidatesViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{
DistributionGroups: []string{"group-one-resource-id"},
Zone: nmdata.CustomZone{
Domain: "test-1.com",
SearchDomainDisabled: false,
Records: []nmdata.SimpleRecord{
{Name: "test.test-1.com", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.1"},
{Name: "test2.test-1.com", Type: int(dns.TypeA), Class: "IN", TTL: 1800, RData: "1.1.1.2"},
{Name: "test3.test-1.com", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test4.test-1.com."},
},
},
})
assert.Contains(t, zoneCandidates, networkmap.AppliedZoneCandidate{
DistributionGroups: []string{"group-two-resources-id"},
Zone: nmdata.CustomZone{
Domain: "test-2.com",
SearchDomainDisabled: true,
Records: []nmdata.SimpleRecord{
{Name: "test2.test-2.com", Type: int(dns.TypeCNAME), Class: "IN", TTL: 1800, RData: "test3.test-2.com."},
},
},
})
}

View File

@@ -0,0 +1,37 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/stretchr/testify/assert"
)
func TestGetDomains(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into domains (id, account_id, domain, target_cluster)
VALUES('domain-1','account-1','test-1.com','target-1.cluster.local')`)
execQuery(t, ctx,
`insert into domains (id, account_id, domain, target_cluster)
VALUES('domain-2','account-1','test-2.com','target-2.cluster.local')`)
execQuery(t, ctx,
`insert into domains (id, account_id, domain, target_cluster)
VALUES('domain-3','account-1',null,null)`)
domains, err := pgstore.GetDomains(ctx, "account-1")
assert.NoError(t, err)
assert.Len(t, domains, 2)
assert.Contains(t, domains, networkmap_pgsql.Domain{
Domain: sql.NullString{String: "test-1.com", Valid: true},
TargetCluster: sql.NullString{String: "target-1.cluster.local", Valid: true},
})
assert.Contains(t, domains, networkmap_pgsql.Domain{
Domain: sql.NullString{String: "test-2.com", Valid: true},
TargetCluster: sql.NullString{String: "target-2.cluster.local", Valid: true},
})
}

View File

@@ -0,0 +1,59 @@
package networkmap_pgsql
import (
"context"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetGroups(t *testing.T) {
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
assert.NoError(t, err)
groups, resourceToGroupIdx, err := s.GetGroups(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t,
groups,
nmdata.Group{ID: "group-one-resource-id", Name: "group-1-name", PublicID: "group-one-resource-id-public", Resources: []nmdata.Resource{{ID: "host-id-1", Type: "host"}}, Peers: []string{"peer-id-1"}},
)
assert.NotNil(t, resourceToGroupIdx["host-id-1"]["group-one-resource-id"])
assert.Contains(t,
groups,
nmdata.Group{ID: "group-two-resources-id", Name: "group-2-name", PublicID: "group-two-resources-id-public",
Resources: []nmdata.Resource{{ID: "subnet-id-1", Type: "subnet"}, {ID: "host-id-2", Type: "host"}},
Peers: []string{"peer-id-2", "peer-id-3"}},
)
assert.NotNil(t, resourceToGroupIdx["host-id-2"]["group-two-resources-id"])
assert.NotNil(t, resourceToGroupIdx["subnet-id-1"]["group-two-resources-id"])
assert.Contains(t,
groups,
nmdata.Group{ID: "group-no-resources-id", Name: "group-3-name", PublicID: "group-no-resources-id-public"})
}
// Verify handling of empty fields in groups table
// Verify that group's PublicID gets populated on retrieval
// TODO (dmitri) PublicID should not be populated with delta updates,
// which require stable PublicIDs
func TestGetGroupsWithoutExpectedFields(t *testing.T) {
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
assert.NoError(t, err)
execQuery(t, ctx,
"insert into accounts (id) VALUES('random-id')")
execQuery(t, ctx,
"insert into groups (id, account_id) VALUES('g2-test-group-id-1','random-id')")
assert.NoError(t, err)
groups, _, err := s.GetGroups(ctx, "random-id")
assert.NoError(t, err)
assert.Len(t, groups, 1)
assert.NotEmpty(t, groups[0].PublicID)
}

View File

@@ -0,0 +1,166 @@
package networkmap_pgsql
import (
"context"
_ "embed"
"fmt"
"os"
"regexp"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/assert"
log "github.com/sirupsen/logrus"
"gorm.io/driver/postgres"
"gorm.io/gorm"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
gormstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/testutil"
)
//go:embed base_data.sql
var baseData string
var (
dsn string
pgstore *networkmap_pgsql.PgStore
)
func TestMain(m *testing.M) {
_, tmpdsn, err := testutil.CreatePostgresTestContainer()
if err != nil {
log.Fatalf("error starting postres container %v", err)
}
var db *gorm.DB
for i := range 5 {
db, err = gorm.Open(postgres.Open(tmpdsn), &gorm.Config{})
if err == nil {
break
}
if i < 5 {
waitTime := time.Duration(100*(i+1)) * time.Millisecond
time.Sleep(waitTime)
continue
}
log.Fatalf("error connecting to postres db %v", err)
}
var cleanup func()
dsn, cleanup, err = createRandomDB(tmpdsn, db)
sqlDB, _ := db.DB()
if sqlDB != nil {
sqlDB.Close()
}
if err != nil {
log.Fatalf("error creating postres db %v", err)
}
_, err = gormstore.NewPostgresqlStoreForTests(context.TODO(), dsn, nil, false)
if err != nil {
log.Fatalf("error running migrations %v", err)
}
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
if err != nil {
log.Fatal("error creating postgres store %w", err)
}
for _, query := range strings.Split(baseData, ";") {
if _, err := s.Pool.Exec(ctx, query); err != nil {
log.Fatalf("error initializing db: %s", err.Error())
}
}
pgstore, err = networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
if err != nil {
log.Fatalf("error creating pg store %v", err.Error())
}
code := m.Run()
cleanup()
os.Exit(code)
}
func createRandomDB(dsn string, db *gorm.DB) (string, func(), error) {
dbName := fmt.Sprintf("test_db_%s", strings.ReplaceAll(uuid.New().String(), "-", "_"))
if err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil {
return "", nil, fmt.Errorf("failed to create database: %v", err)
}
originalDSN := dsn
cleanup := func() {
var dropDB *gorm.DB
var err error
dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{
SkipDefaultTransaction: true,
PrepareStmt: false,
})
if err != nil {
log.Errorf("failed to connect for dropping database %s: %v", dbName, err)
return
}
defer func() {
if sqlDB, _ := dropDB.DB(); sqlDB != nil {
sqlDB.Close()
}
}()
if sqlDB, _ := dropDB.DB(); sqlDB != nil {
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(0)
sqlDB.SetConnMaxLifetime(time.Second)
}
err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error
if err != nil {
log.Errorf("failed to drop database %s: %v", dbName, err)
}
}
return replaceDBName(dsn, dbName), cleanup, nil
}
func replaceDBName(dsn, newDBName string) string {
re := regexp.MustCompile(`(?P<pre>[:/@])(?P<dbname>[^/?]+)(?P<post>\?|$)`)
return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`)
}
func conn(t *testing.T, ctx context.Context) *pgx.Conn {
t.Helper()
c, err := pgstore.Pool.Acquire(ctx)
assert.NoError(t, err)
return c.Conn()
}
func execQuery(t *testing.T, ctx context.Context, q string) {
t.Helper()
_, err := pgstore.Pool.Exec(ctx, q)
assert.NoError(t, err)
}
// use to parse time in time.RFC3339Nano format
// returns the time in the local time zone, as that's what being returned from sql queries
func mustParseTime(t string) *time.Time {
tt, err := time.Parse(time.RFC3339Nano, t)
if err != nil {
panic(err)
}
local := tt.Local()
return &local
}

View File

@@ -0,0 +1,60 @@
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNameServerGroups(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled, "primary", account_id)
VALUES('nsgroup-1','nsgroup-1-public','nsgroup-1','nsgroup-1','[{"IP":"192.168.31.2","NSType":1,"Port":53}]','["group-one-resource-id"]','["test-1.com"]',TRUE,FALSE,TRUE,'account-1')`)
execQuery(t, ctx,
`insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id)
VALUES('nsgroup-2','nsgroup-2-public','nsgroup-2','nsgroup-2','[{"IP":"192.168.32.3","NSType":1,"Port":53}]','["group-one-resource-id","group-no-resources-id"]','["test-1.com","test-2.com"]',TRUE,FALSE,TRUE,'account-1')`)
execQuery(t, ctx,
`insert into name_server_groups (id, public_id, name, description, name_servers, groups, domains, enabled, search_domains_enabled,"primary",account_id)
VALUES('nsgroup-3','nsgroup-3-public',null,null,null,null,null,TRUE,FALSE,FALSE,'account-1')`)
nsgroups, err := networkmap_pgsql.GetNameServerGroupsViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Contains(t, nsgroups, nmdata.NameServerGroup{
ID: "nsgroup-1",
PublicID: "nsgroup-1-public",
Name: "nsgroup-1",
Description: "nsgroup-1",
NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.31.2"), NSType: 1, Port: 53}},
Groups: []string{"group-one-resource-id"},
Domains: []string{"test-1.com"},
Primary: true,
SearchDomainsEnabled: false,
Enabled: true,
})
assert.Contains(t, nsgroups, nmdata.NameServerGroup{
ID: "nsgroup-2",
PublicID: "nsgroup-2-public",
Name: "nsgroup-2",
Description: "nsgroup-2",
NameServers: []nmdata.NameServer{{IP: netip.MustParseAddr("192.168.32.3"), NSType: 1, Port: 53}},
Groups: []string{"group-one-resource-id", "group-no-resources-id"},
Domains: []string{"test-1.com", "test-2.com"},
Primary: true,
SearchDomainsEnabled: false,
Enabled: true,
})
assert.Contains(t, nsgroups, nmdata.NameServerGroup{
ID: "nsgroup-3",
PublicID: "nsgroup-3-public",
Primary: false,
SearchDomainsEnabled: false,
Enabled: true,
})
}

View File

@@ -0,0 +1,67 @@
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNetworkResources(t *testing.T) {
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
assert.NoError(t, err)
execQuery(t, ctx,
`insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled)
VALUES('net-resource-1','account-1','network-1','net-resource-public-1','network-resource-1','network-resource-1','subnet','','"10.0.0.0/16"',TRUE)`)
execQuery(t, ctx,
`insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled)
VALUES('net-resource-2','account-1','network-2','net-resource-public-2','network-resource-2','network-resource-2','domain','test.com','',TRUE)`)
execQuery(t, ctx,
`insert into network_resources (id, account_id, network_id, public_id, name, description, type, domain, prefix, enabled)
VALUES('net-resource-3','account-1','network-3','net-resource-public-3','network-resource-3','network-resource-3','host','','"10.0.0.1/32"',TRUE)`)
resources, err := s.GetNetworkResources(ctx, "account-1")
assert.NoError(t, err)
assert.Contains(t, resources, nmdata.NetworkResource{
ID: "net-resource-1",
AccountID: "account-1",
NetworkID: "network-1",
PublicID: "net-resource-public-1",
Name: "network-resource-1",
Description: "network-resource-1",
Type: "subnet",
Domain: "",
Prefix: netip.MustParsePrefix("10.0.0.0/16"),
Enabled: true,
})
assert.Contains(t, resources, nmdata.NetworkResource{
ID: "net-resource-2",
AccountID: "account-1",
NetworkID: "network-2",
PublicID: "net-resource-public-2",
Name: "network-resource-2",
Description: "network-resource-2",
Type: "domain",
Domain: "test.com",
Enabled: true,
})
assert.Contains(t, resources, nmdata.NetworkResource{
ID: "net-resource-3",
AccountID: "account-1",
NetworkID: "network-3",
PublicID: "net-resource-public-3",
Name: "network-resource-3",
Description: "network-resource-3",
Type: "host",
Domain: "",
Prefix: netip.MustParsePrefix("10.0.0.1/32"),
Enabled: true,
})
}

View File

@@ -0,0 +1,35 @@
package networkmap_pgsql
import (
"context"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNetworkRouters(t *testing.T) {
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
assert.NoError(t, err)
execQuery(t, ctx,
`insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups)
VALUES('test-nr-id-1','account-1','public-id-1','peer-id-1','network-id-1',TRUE,999,TRUE,'["group-one-resource-id"]')`)
execQuery(t, ctx,
`insert into network_routers (id, account_id, public_id, peer, network_id, masquerade, metric, enabled, peer_groups)
VALUES('test-nr-id-2','account-1','public-id-2','','network-id-2',TRUE,333,TRUE,'["group-two-resources-id","group-no-resources-id"]')`)
routers, err := s.GetNetworkRouters(ctx, "account-1")
assert.NoError(t, err)
assert.NotEmpty(t, routers)
assert.Equal(t, routers["network-id-1"],
map[string]*nmdata.NetworkRouter{"peer-id-1": {PublicID: "public-id-1", Masquerade: true, Metric: 999, Enabled: true, PeerGroups: []string{"group-one-resource-id"}}})
assert.Equal(t, routers["network-id-2"],
map[string]*nmdata.NetworkRouter{
"peer-id-2": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}},
"peer-id-3": {PublicID: "public-id-2", Masquerade: true, Metric: 333, Enabled: true, PeerGroups: []string{"group-two-resources-id", "group-no-resources-id"}}})
}

View File

@@ -0,0 +1,58 @@
package networkmap_pgsql
import (
"context"
"encoding/json"
"net"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetNetwork(t *testing.T) {
ctx := context.TODO()
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
assert.NoError(t, err)
network, err := s.GetNetwork(ctx, "account-1")
assert.NoError(t, err)
assert.Equal(t, network, nmdata.Network{
Identifier: "network-1",
Net: mustParseCIDR("100.103.0.0/16"),
NetV6: mustParseCIDR("fdde:e995:fd38:a465::/64"),
Serial: 1,
})
network, err = s.GetNetwork(ctx, "account-2")
assert.NoError(t, err)
assert.Equal(t, network, nmdata.Network{
Identifier: "network-2",
Net: mustParseCIDR("110.0.0.0/16"),
NetV6: mustParseCIDR("fddf:e995:fd38:a465::/64"),
Serial: 2,
})
}
func mustParseCIDR(s string) net.IPNet {
var toret net.IPNet
_, net, err := net.ParseCIDR(s)
if err != nil {
panic(err)
}
jn, err := json.Marshal(net)
if err != nil {
panic(err)
}
err = json.Unmarshal(jn, &toret)
if err != nil {
panic(err)
}
return toret
}

View File

@@ -0,0 +1,25 @@
package networkmap_pgsql
import (
"context"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/stretchr/testify/assert"
)
func TestGetNetworks(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into networks (id, account_id, public_id) VALUES('network-1','account-1','network-1-public')`)
execQuery(t, ctx,
`insert into networks (id, account_id, public_id) VALUES('network-2','account-1','network-2-public')`)
networksIdx, err := networkmap_pgsql.GetNetworkXIDToPublicIdMapViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Equal(t, networksIdx, map[string]string{
"network-1": "network-1-public",
"network-2": "network-2-public",
})
}

View File

@@ -0,0 +1,162 @@
package networkmap_pgsql
import (
"context"
"net"
"net/netip"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetPeers(t *testing.T) {
ctx := context.TODO()
peers, clusterToPeersIdx, err := networkmap_pgsql.GetPeersViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
// shouldn't be returned in the index, as it's not connected
execQuery(t, ctx,
`insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected)
values('peer-4','account-1','key-4','ssh-key-4',true,false)`)
// shouldn't be returned in the index as it doesn't have cluster set
execQuery(t, ctx,
`insert into peers (id,account_id,"key",ssh_key,proxy_meta_embedded,peer_status_connected)
values('peer-5','account-1','key-5','ssh-key-5',false,true)`)
peer1 := nmdata.Peer{
ID: "peer-id-1",
Key: "key-1",
SSHKey: "ssh-key-1",
DNSLabel: "peer-1",
ExtraDNSLabels: []string{"extra-peer-1"},
UserID: "user-id-1",
SSHEnabled: true,
LoginExpirationEnabled: true,
LastLogin: mustParseTime("2026-08-06T13:25:59.12999+00:00"),
IP: netip.MustParseAddr("10.10.10.1"),
IPv6: netip.MustParseAddr("fdf4:ba80:6aa5:89f1:44d7:8701:8699:4940"),
RequiresApproval: false,
Meta: nmdata.PeerSystemMeta{
WtVersion: "0.76.0",
GoOS: "linux",
OSVersion: "26.4.1",
KernelVersion: "6.8.0-134-generic",
NetworkAddresses: []nmdata.NetworkAddress{
{NetIP: netip.MustParsePrefix("fe80::8b4c:973f:a76b:3771/64")},
{NetIP: netip.MustParsePrefix("192.168.16.1/20")},
},
Files: []nmdata.File{
{Path: "/usr/bin/netbird", ProcessIsRunning: false},
},
Capabilities: []int32{1, 2},
Flags: nmdata.Flags{
ServerSSHAllowed: true,
DisableIPv6: false,
},
SyncMessageVersion: 1,
},
ProxyMeta: nmdata.ProxyMeta{
Embedded: true,
},
Location: nmdata.PeerLocation{
CountryCode: "DE",
CityName: "Berlin",
ConnectionIP: net.ParseIP("46.201.148.187"),
},
}
peer2 := nmdata.Peer{
ID: "peer-id-2",
Key: "key-2",
SSHKey: "ssh-key-2",
DNSLabel: "peer-2",
ExtraDNSLabels: []string{"extra-peer-2"},
UserID: "user-id-2",
SSHEnabled: true,
LoginExpirationEnabled: true,
LastLogin: mustParseTime("2026-08-06T14:25:59.12999+00:00"),
IP: netip.MustParseAddr("10.10.100.1"),
IPv6: netip.MustParseAddr("fdf5:ba80:6aa5:89f1:44d7:8701:8699:4940"),
RequiresApproval: false,
Meta: nmdata.PeerSystemMeta{
WtVersion: "0.76.1",
GoOS: "linux",
OSVersion: "26.4.2",
KernelVersion: "6.8.0-135-generic",
NetworkAddresses: []nmdata.NetworkAddress{
{NetIP: netip.MustParsePrefix("fe81::8b4c:973f:a76b:3771/64")},
{NetIP: netip.MustParsePrefix("192.168.17.1/20")},
},
Files: []nmdata.File{
{Path: "/usr/bin/netbird", ProcessIsRunning: false},
},
Capabilities: []int32{1, 2},
Flags: nmdata.Flags{
ServerSSHAllowed: true,
DisableIPv6: false,
},
SyncMessageVersion: 0,
},
ProxyMeta: nmdata.ProxyMeta{
Embedded: true,
},
Location: nmdata.PeerLocation{
CountryCode: "DE",
CityName: "Berlin",
ConnectionIP: net.ParseIP("46.201.149.187"),
},
}
peer3 := nmdata.Peer{
ID: "peer-id-3",
Key: "key-3",
SSHKey: "ssh-key-3",
DNSLabel: "peer-3",
ExtraDNSLabels: []string{"extra-peer-3"},
UserID: "user-id-3",
SSHEnabled: true,
LoginExpirationEnabled: true,
LastLogin: mustParseTime("2026-08-06T12:25:59.12999+00:00"),
IP: netip.MustParseAddr("10.10.200.1"),
IPv6: netip.MustParseAddr("fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"),
RequiresApproval: false,
Meta: nmdata.PeerSystemMeta{
WtVersion: "0.76.2",
GoOS: "linux",
OSVersion: "26.4.3",
KernelVersion: "6.8.0-136-generic",
NetworkAddresses: []nmdata.NetworkAddress{
{NetIP: netip.MustParsePrefix("fe82::8b4c:973f:a76b:3771/64")},
{NetIP: netip.MustParsePrefix("192.168.18.1/20")},
},
Files: []nmdata.File{
{Path: "/usr/bin/netbird", ProcessIsRunning: false},
},
Capabilities: []int32{1, 2},
Flags: nmdata.Flags{
ServerSSHAllowed: true,
DisableIPv6: false,
},
SyncMessageVersion: 1,
},
ProxyMeta: nmdata.ProxyMeta{
Embedded: true,
},
Location: nmdata.PeerLocation{
CountryCode: "DE",
CityName: "Berlin",
ConnectionIP: net.ParseIP("46.201.150.187"),
},
}
assert.Contains(t, peers, peer1)
assert.Contains(t, peers, peer2)
assert.Contains(t, peers, peer3)
assert.Equal(t, clusterToPeersIdx, map[string][]*nmdata.Peer{
"cluster-1.netbird.services": {&peer1},
"cluster-2.netbird.services": {&peer2},
"cluster-3.netbird.services": {&peer3},
})
}

View File

@@ -0,0 +1,145 @@
package networkmap_pgsql
import (
"context"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetPolicies(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
values('policy-1','policy-1-public','account-1',true,'["posture-checks-1","posture-checks-2"]')`)
execQuery(t, ctx,
`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
source_resource, destination_resource, ports, port_ranges,
authorized_groups, authorized_user)
values('policy-1-rule-1','policy-1',true,'accept','tcp',true,'["group-one-resource-id","group-two-resources-id"]','["group-one-resource-id","group-two-resources-id"]',
'{"ID":"host-id-1","Type":"host"}','{"ID":"domain-1","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
'{"group-one-resource-id":["user-1", "user-2"]}','user-3')`)
execQuery(t, ctx,
`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
values('policy-2','policy-2-public','account-1',true,'["posture-checks-3","posture-checks-4"]')`)
execQuery(t, ctx,
`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
source_resource, destination_resource, ports, port_ranges,
authorized_groups, authorized_user)
values('policy-2-rule-1','policy-2',true,'accept','tcp',true,'["group-one-resource-id"]','["group-two-resources-id"]',
'{"ID":"host-id-3","Type":"host"}','{"ID":"domain-3","Type":"domain"}','["8080","8443"]', '[{"Start":8080,"End":8090}]',
'{"group-one-resource-id":["user-6", "user-7"]}','user-8')`)
// policy with a rule with null fields
execQuery(t, ctx,
`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
values('policy-3','policy-3-public','account-1',true,null)`)
execQuery(t, ctx,
`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
source_resource, destination_resource, ports, port_ranges,
authorized_groups, authorized_user)
values('policy-3-rule-1','policy-3',true,null,null,null,null,null,null,null,null,null,null,null)`)
// policy with a disabled rule, destination resource and groups should not be in indexes
execQuery(t, ctx,
`insert into policies (id, public_id, account_id, enabled, source_posture_checks)
values('policy-4','policy-4-public','account-1',true,null)`)
execQuery(t, ctx,
`insert into policy_rules (id, policy_id, enabled, action, protocol, bidirectional, sources, destinations,
source_resource, destination_resource, ports, port_ranges,
authorized_groups, authorized_user)
values('policy-4-rule-1','policy-4',false,null,null,null,null,'["group-two-resources-id"]',
null,'{"ID":"domain-3","Type":"domain"}',null,null,null,null)`)
policies, policyToDestinationResourceIdx, policyToDestinationGroupIdx, err := networkmap_pgsql.GetPoliciesViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Contains(t, policies, nmdata.Policy{
ID: "policy-1",
PublicID: "policy-1-public",
Enabled: true,
SourcePostureChecks: []string{"posture-checks-1", "posture-checks-2"},
Rules: []*nmdata.PolicyRule{
{
ID: "policy-1",
PolicyID: "policy-1",
Enabled: true,
Action: "accept",
Protocol: "tcp",
Bidirectional: true,
Sources: []string{"group-one-resource-id", "group-two-resources-id"},
Destinations: []string{"group-one-resource-id", "group-two-resources-id"},
SourceResource: nmdata.Resource{ID: "host-id-1", Type: "host"},
DestinationResource: nmdata.Resource{ID: "domain-1", Type: "domain"},
Ports: []string{"8080", "8443"},
PortRanges: []nmdata.RulePortRange{{Start: 8080, End: 8090}},
AuthorizedGroups: map[string][]string{"group-one-resource-id": {"user-1", "user-2"}},
AuthorizedUser: "user-3",
},
},
})
assert.Contains(t, policies, nmdata.Policy{
ID: "policy-2",
PublicID: "policy-2-public",
Enabled: true,
SourcePostureChecks: []string{"posture-checks-3", "posture-checks-4"},
Rules: []*nmdata.PolicyRule{
{
ID: "policy-2",
PolicyID: "policy-2",
Enabled: true,
Action: "accept",
Protocol: "tcp",
Bidirectional: true,
Sources: []string{"group-one-resource-id"},
Destinations: []string{"group-two-resources-id"},
SourceResource: nmdata.Resource{ID: "host-id-3", Type: "host"},
DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"},
Ports: []string{"8080", "8443"},
PortRanges: []nmdata.RulePortRange{{Start: 8080, End: 8090}},
AuthorizedGroups: map[string][]string{"group-one-resource-id": {"user-6", "user-7"}},
AuthorizedUser: "user-8",
},
},
})
assert.Contains(t, policies, nmdata.Policy{
ID: "policy-3",
PublicID: "policy-3-public",
Enabled: true,
SourcePostureChecks: []string{},
Rules: []*nmdata.PolicyRule{
{
ID: "policy-3",
PolicyID: "policy-3",
Enabled: true,
},
},
})
assert.Contains(t, policies, nmdata.Policy{
ID: "policy-4",
PublicID: "policy-4-public",
Enabled: true,
SourcePostureChecks: []string{},
Rules: []*nmdata.PolicyRule{
{
ID: "policy-4",
PolicyID: "policy-4",
Enabled: false,
Destinations: []string{"group-two-resources-id"},
DestinationResource: nmdata.Resource{ID: "domain-3", Type: "domain"},
},
},
})
assert.Equal(t, policyToDestinationGroupIdx, map[string]map[string]any{
"policy-1": {"group-one-resource-id": struct{}{}, "group-two-resources-id": struct{}{}},
"policy-2": {"group-two-resources-id": struct{}{}},
})
assert.Equal(t, policyToDestinationResourceIdx, map[string]map[string]any{
"policy-1": {"domain-1": struct{}{}},
"policy-2": {"domain-3": struct{}{}},
})
}

View File

@@ -0,0 +1,60 @@
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetPostureChecks(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into posture_checks (id, account_id, public_id, checks)
VALUES('posturecheck-1','account-1','posturecheck-1-public',
'{"NBVersionCheck":{"MinVersion":"0.25.0"},
"OSVersionCheck":{"Darwin":{"MinVersion":"12.0"}},
"GeoLocationCheck":{"Locations":[{"CountryCode":"FI","CityName":""}],"Action":"allow"},
"PeerNetworkRangeCheck":{"Action":"deny","Ranges":["192.168.0.1/24"]}}')`)
execQuery(t, ctx,
`insert into posture_checks (id, account_id, public_id, checks)
VALUES('posturecheck-2','account-1','posturecheck-2-public',
'{"NBVersionCheck":{"MinVersion":"0.25.0"},
"OSVersionCheck":{"Android":{"MinVersion":"0"}},
"GeoLocationCheck":{"Locations":[{"CountryCode":"US","CityName":"Harker Heights"}],"Action":"allow"},
"PeerNetworkRangeCheck":{"Action":"allow","Ranges":["0.0.0.0/0"]}}')`)
execQuery(t, ctx,
`insert into posture_checks (id, account_id, public_id, checks)
VALUES('posturecheck-3','account-1','posturecheck-3-public', null)`)
postureChecks, idToPublicIDIdx, err := networkmap_pgsql.GetPostureChecksViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Equal(t, idToPublicIDIdx, map[string]string{
"posturecheck-1": "posturecheck-1-public",
"posturecheck-2": "posturecheck-2-public",
"posturecheck-3": "posturecheck-3-public",
})
assert.Contains(t, postureChecks, nmdata.PostureChecks{
ID: "posturecheck-1",
Checks: nmdata.ChecksDefinition{
NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
OSVersionCheck: &nmdata.OSVersionCheck{Darwin: &nmdata.MinVersionCheck{MinVersion: "12.0"}},
GeoLocationCheck: &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "FI"}}, Action: "allow"},
PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "deny", Ranges: []netip.Prefix{netip.MustParsePrefix("192.168.0.1/24")}},
}})
assert.Contains(t, postureChecks, nmdata.PostureChecks{
ID: "posturecheck-2",
Checks: nmdata.ChecksDefinition{
NBVersionCheck: &nmdata.NBVersionCheck{MinVersion: "0.25.0"},
OSVersionCheck: &nmdata.OSVersionCheck{Android: &nmdata.MinVersionCheck{MinVersion: "0"}},
GeoLocationCheck: &nmdata.GeoLocationCheck{Locations: []nmdata.GeoLocation{{CountryCode: "US", CityName: "Harker Heights"}}, Action: "allow"},
PeerNetworkRangeCheck: &nmdata.PeerNetworkRangeCheck{Action: "allow", Ranges: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}},
}})
assert.Contains(t, postureChecks, nmdata.PostureChecks{
ID: "posturecheck-3"})
}

View File

@@ -0,0 +1,86 @@
package networkmap_pgsql
import (
"context"
"net/netip"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
func TestGetRoutes(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
peer, peer_groups, network_type, masquerade, metric, enabled,
groups, access_control_groups, skip_auto_apply)
VALUES('route-1','account-1','route-1-public','"172.0.0.0/16"','["test-1.com"]',true,'route-1-net-id','route-1',
'peer-id-1','["group-one-resource-id"]',1,true,9999,true,
'["group-one-resource-id"]','["group-one-resource-id"]',false)`)
execQuery(t, ctx,
`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
peer, peer_groups, network_type, masquerade, metric, enabled,
groups, access_control_groups, skip_auto_apply)
VALUES('route-2','account-1','route-2-public','"172.10.0.0/16"','["test-1.com","test-2.com"]',true,'route-2-net-id','route-2',
'peer-id-2','["group-two-resources-id"]',1,true,9999,true,
'["group-two-resources-id"]','["group-two-resources-id"]',false)`)
execQuery(t, ctx,
`insert into routes (id, account_id, public_id, network, domains, keep_route, net_id, description,
peer, peer_groups, network_type, masquerade, metric, enabled,
groups, access_control_groups, skip_auto_apply)
VALUES('route-3','account-1','route-3-public',null,null,null,null,'route-3',
null,null,null,null,null,null,null,null,null)`)
routes, err := networkmap_pgsql.GetRoutesViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Contains(t, routes, nmdata.Route{
ID: "route-1",
AccountID: "account-1",
PublicID: "route-1-public",
Network: netip.MustParsePrefix("172.0.0.0/16"),
Domains: domain.List{"test-1.com"},
KeepRoute: true,
NetID: "route-1-net-id",
Description: "route-1",
Peer: "peer-id-1",
PeerID: "peer-id-1",
PeerGroups: []string{"group-one-resource-id"},
NetworkType: 1,
Masquerade: true,
Metric: 9999,
Enabled: true,
Groups: []string{"group-one-resource-id"},
AccessControlGroups: []string{"group-one-resource-id"},
SkipAutoApply: false,
})
assert.Contains(t, routes, nmdata.Route{
ID: "route-2",
AccountID: "account-1",
PublicID: "route-2-public",
Network: netip.MustParsePrefix("172.10.0.0/16"),
Domains: domain.List{"test-1.com", "test-2.com"},
KeepRoute: true,
NetID: "route-2-net-id",
Description: "route-2",
Peer: "peer-id-2",
PeerID: "peer-id-2",
PeerGroups: []string{"group-two-resources-id"},
NetworkType: 1,
Masquerade: true,
Metric: 9999,
Enabled: true,
Groups: []string{"group-two-resources-id"},
AccessControlGroups: []string{"group-two-resources-id"},
SkipAutoApply: false,
})
assert.Contains(t, routes, nmdata.Route{
ID: "route-3",
AccountID: "account-1",
PublicID: "route-3-public",
Description: "route-3",
})
}

View File

@@ -0,0 +1,110 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
)
func TestGetPrivateServicesViaPgxConnection(t *testing.T) {
ctx := context.TODO()
_, err := pgstore.Pool.Exec(ctx,
`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
values('service-1','account-1',true,true,'["group-one-resource-id"]','test-1.com','test-2.com')`)
assert.NoError(t, err)
execQuery(t, ctx,
`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
values('service-2','account-1',true,true,'["group-one-resource-id","group-two-resources-id"]','test-3.com','test-4.com')`)
assert.NoError(t, err)
execQuery(t, ctx,
`insert into services (id, account_id, enabled, private, access_groups, proxy_cluster, domain)
values('service-3','account-1',null,null,null,null,null)`)
assert.NoError(t, err)
services, err := networkmap_pgsql.GetPrivateServicesViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Contains(t, services, networkmap_pgsql.Service{
Enabled: sql.NullBool{Bool: true, Valid: true},
Private: sql.NullBool{Bool: true, Valid: true},
AccessGroups: []string{"group-one-resource-id"},
ProxyCluster: sql.NullString{String: "test-1.com", Valid: true},
Domain: sql.NullString{String: "test-2.com", Valid: true},
})
assert.Contains(t, services, networkmap_pgsql.Service{
Enabled: sql.NullBool{Bool: true, Valid: true},
Private: sql.NullBool{Bool: true, Valid: true},
AccessGroups: []string{"group-one-resource-id", "group-two-resources-id"},
ProxyCluster: sql.NullString{String: "test-3.com", Valid: true},
Domain: sql.NullString{String: "test-4.com", Valid: true},
})
assert.Contains(t, services, networkmap_pgsql.Service{
Enabled: sql.NullBool{Bool: false, Valid: false},
Private: sql.NullBool{Bool: false, Valid: false},
AccessGroups: []string{},
ProxyCluster: sql.NullString{String: "", Valid: false},
Domain: sql.NullString{String: "", Valid: false},
})
}
func TestGetProxyTargetedDomainResourceIDsViaPgxConnection(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into services (id, account_id, enabled, terminated)
values('service-4','account-1',true,false)`)
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-1','account-1','service-4',true,'domain')`)
// id shouldn't be returned as the taget_type is not "domain"
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-2','account-1','service-4',true,'cluster')`)
// id shouldn't be included as the target is disabled
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-3','account-1','service-4',false,'domain')`)
// id shouldn't be included as the service is disabled
execQuery(t, ctx,
`insert into services (id, account_id, enabled, terminated)
values('service-5','account-1',false,false)`)
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-4','account-1','service-5',false,'domain')`)
// id shouldn't be included as the service is terminated (explicitly)
execQuery(t, ctx,
`insert into services (id, account_id, enabled, terminated)
values('service-6','account-1',true,true)`)
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-5','account-1','service-6',true,'domain')`)
// id shouldn't be included as the service is terminated (implicitly)
execQuery(t, ctx,
`insert into services (id, account_id, enabled, terminated)
values('service-7','account-1',true,null)`)
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-6','account-1','service-7',true,'domain')`)
execQuery(t, ctx,
`insert into services (id, account_id, enabled, terminated)
values('service-8','account-1',true,false)`)
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values('target-7','account-1','service-8',true,'domain')`)
// id shouldn't be returned as the taget_id is null
execQuery(t, ctx,
`insert into targets (target_id, account_id, service_id, enabled, target_type)
values(null,'account-1','service-4',true,'cluster')`)
servtargetedDomains, err := networkmap_pgsql.GetProxyTargetedDomainResourceIDsViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Equal(t, servtargetedDomains, map[string]struct{}{
"target-1": {},
"target-6": {},
"target-7": {},
})
}

View File

@@ -0,0 +1,56 @@
package networkmap_pgsql
import (
"context"
"testing"
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
"github.com/stretchr/testify/assert"
)
func TestGetAllowedUsers(t *testing.T) {
ctx := context.TODO()
execQuery(t, ctx,
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
VALUES('user-1','user-1','account-1','["group-one-resource-id"]',false,false)`)
execQuery(t, ctx,
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
VALUES('user-2','user-2','account-1','["group-one-resource-id","group-two-resources-id"]',false,false)`)
execQuery(t, ctx,
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
VALUES('user-3','user-3','account-1','["group-two-resources-id"]',false,false)`)
// shouldn't be included as it's blocked
execQuery(t, ctx,
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
VALUES('user-4','user-4','account-1','["group-two-resources-id"]',true,false)`)
// shouldn't be included as it's a service_user
execQuery(t, ctx,
`insert into users (id, name, account_id, auto_groups, blocked, is_service_user)
VALUES('user-5','user-5','account-1','["group-two-resources-id"]',false,true)`)
execQuery(t, ctx,
`insert into groups (id, name, account_id)
VALUES('all-group-1','All','account-1')`)
execQuery(t, ctx,
`insert into groups (id, name, account_id)
VALUES('all-group-2','All','account-1')`)
execQuery(t, ctx,
`insert into groups (id, name, account_id)
VALUES('all-group-3','All','account-1')`)
userIdx, groupIdToUserIds, err := networkmap_pgsql.GetAllowedUsersViaPgxConnection(ctx, conn(t, ctx), "account-1")
assert.NoError(t, err)
assert.Equal(t, userIdx, map[string]struct{}{
"user-1": {},
"user-2": {},
"user-3": {},
})
assert.Equal(t, groupIdToUserIds, map[string][]string{
"group-one-resource-id": {"user-1", "user-2"},
"group-two-resources-id": {"user-2", "user-3"},
"all-group-1": {"user-1", "user-2", "user-3"},
"all-group-2": {"user-1", "user-2", "user-3"},
"all-group-3": {"user-1", "user-2", "user-3"},
})
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/account"
@@ -30,6 +31,8 @@ import (
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/management/server/types"
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
"github.com/netbirdio/netbird/util"
@@ -61,6 +64,8 @@ type Controller struct {
serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion
perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion
nmdataStore *networkmapdb.NetworkMapDBStoreImpl
}
type bufferUpdate struct {
@@ -78,7 +83,7 @@ type bufferAffectedUpdate struct {
var _ network_map.Controller = (*Controller)(nil)
func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config) *Controller {
func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller {
nMetrics, err := newMetrics(metrics.UpdateChannelMetrics())
if err != nil {
log.Fatal(fmt.Errorf("error creating metrics: %w", err))
@@ -99,6 +104,7 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App
EphemeralPeersManager: ephemeralPeersManager,
serverSupportedSyncMessageVersion: sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion),
perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion),
nmdataStore: nmdataStore,
}
}
@@ -147,6 +153,11 @@ func (c *Controller) CountStreams() int {
func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
log.WithContext(ctx).Tracef("updating peers for account %s from %s", accountID, util.GetCallerName())
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.sendUpdateAccountPeersFromData(ctx, accountID, reason, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return fmt.Errorf("failed to get account: %v", err)
@@ -167,7 +178,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
return nil
}
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validate peers: %v", err)
}
@@ -254,7 +265,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
// the client merges it into Calculate()'s output the same
// way the legacy server did via NetworkMap.Merge.
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -275,7 +286,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
}
start = time.Now()
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -293,6 +304,259 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
return nil
}
// sendUpdateAccountPeersFromData is the account-free variant of
// sendUpdateAccountPeers: everything is computed from the network-map DB
// store's twin data; only extra settings and validated peers are resolved at
// runtime. Proxy network maps and policy injection, private-service zones,
// group-to-user SSH mappings and forced routing-peer DNS resolution have no
// DB-backed source yet and are omitted.
func (c *Controller) sendUpdateAccountPeersFromData(ctx context.Context, accountID string, reason types.UpdateReason, nmData *networkmap.NetworkMapData) error {
peersToUpdate := c.connectedPeersFromData(nmData, nil)
if len(peersToUpdate) == 0 {
return nil
}
return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, &reason)
}
// sendUpdateForAffectedPeersFromData is the account-free variant of
// sendUpdateForAffectedPeers.
func (c *Controller) sendUpdateForAffectedPeersFromData(ctx context.Context, accountID string, peerIDs []string, nmData *networkmap.NetworkMapData) error {
if len(peerIDs) == 0 {
log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no affected peers")
return nil
}
peersToUpdate := c.connectedPeersFromData(nmData, peerIDs)
if len(peersToUpdate) == 0 {
log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: no peers to update (affected peers not found in data or no channels)")
return nil
}
log.WithContext(ctx).Tracef("sendUpdateForAffectedPeersFromData: sending network map to %d connected peers", len(peersToUpdate))
return c.sendUpdatesFromData(ctx, accountID, nmData, peersToUpdate, nil)
}
// connectedPeersFromData returns the peers with an open update channel. An
// empty affected list means all peers; a non-empty list restricts the result
// to those peer IDs.
func (c *Controller) connectedPeersFromData(nmData *networkmap.NetworkMapData, affected []string) []*nmdata.Peer {
if len(affected) == 0 {
result := make([]*nmdata.Peer, 0, len(nmData.Peers))
for _, peer := range nmData.Peers {
if c.peersUpdateManager.HasChannel(peer.ID) {
result = append(result, peer)
}
}
return result
}
result := make([]*nmdata.Peer, 0, len(affected))
for _, peerID := range affected {
peer := nmData.Peers[peerID]
if peer == nil {
continue
}
if c.peersUpdateManager.HasChannel(peerID) {
result = append(result, peer)
}
}
return result
}
func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string, nmData *networkmap.NetworkMapData, peersToUpdate []*nmdata.Peer, reason *types.UpdateReason) error {
globalStart := time.Now()
extraSettings, err := c.settingsManager.GetExtraSettings(ctx, accountID)
if err != nil {
return fmt.Errorf("failed to get flow enabled status: %v", err)
}
dnsCache := &cache.DNSConfigCache{}
dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, ipv6AllowedPeersFromData(nmData))
dnsFwdPort := computeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
var wg sync.WaitGroup
semaphore := make(chan struct{}, 10)
for _, peer := range peersToUpdate {
if reason != nil && c.accountManagerMetrics != nil {
c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation))
}
wg.Add(1)
semaphore <- struct{}{}
go func(p *nmdata.Peer) {
defer wg.Done()
defer func() { <-semaphore }()
start := time.Now()
postureChecks := peerPostureChecksFromData(nmData, p.ID)
c.metrics.CountCalcPostureChecksDuration(time.Since(start))
start = time.Now()
peerGroups := maps.Keys(nmData.GetPeerGroups(p.ID))
var update *proto.SyncResponse
commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion(
c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion))
log.WithContext(ctx).
WithFields(log.Fields{
"sync_message_version": commonSyncMessageVersion,
"server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(accountID),
"peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&p.Meta.SyncMessageVersion),
}).Debug("common highest sync message version")
if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap {
components := nmData.GetPeerNetworkMapComponents(p.ID, peersCustomZone)
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
start = time.Now()
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
Update: update,
MessageType: network_map.MessageTypeNetworkMap,
})
return
}
nmap := networkMapFromData(ctx, nmData, p.ID, peersCustomZone)
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
start = time.Now()
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort)
c.metrics.CountToSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
Update: update,
MessageType: network_map.MessageTypeNetworkMap,
})
}(peer)
}
wg.Wait()
if c.accountManagerMetrics != nil {
c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart))
}
return nil
}
func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *networkmap.NetworkMapData {
if c.nmdataStore == nil {
return nil
}
nmData, err := c.nmdataStore.GetNetworkMapData(ctx, accountID)
if err != nil {
log.WithContext(ctx).Errorf("failed to get network map data for account %s, falling back to account-based computation: %v", accountID, err)
return nil
}
return nmData
}
func (c *Controller) getDNSDomainFromData(settings *nmdata.AccountSettingsInfo) string {
if settings == nil || settings.DNSDomain == "" {
return c.dnsDomain
}
return settings.DNSDomain
}
func ipv6AllowedPeersFromData(nmData *networkmap.NetworkMapData) map[string]struct{} {
result := make(map[string]struct{})
if nmData.AccountSettings != nil {
for _, groupID := range nmData.AccountSettings.IPv6EnabledGroups {
group := nmData.Groups[groupID]
if group == nil {
continue
}
for _, peerID := range group.Peers {
result[peerID] = struct{}{}
}
}
}
for id, p := range nmData.Peers {
if p != nil && p.ProxyMeta.Embedded {
result[id] = struct{}{}
}
}
return result
}
func networkMapFromData(ctx context.Context, nmData *networkmap.NetworkMapData, peerID string, peersCustomZone nmdata.CustomZone) *types.NetworkMap {
components := nmData.GetPeerNetworkMapComponents(peerID, peersCustomZone)
if components.IsEmpty() {
return &types.NetworkMap{Network: components.Network}
}
return types.CalculateNetworkMapFromComponents(ctx, components)
}
// peerPostureChecksFromData mirrors getPeerPostureChecks on the twin store. The
// sync response only encodes process-check file paths, so only ProcessCheck is
// converted back to the server posture type.
func peerPostureChecksFromData(nmData *networkmap.NetworkMapData, peerID string) []*posture.Checks {
if len(nmData.PostureChecks) == 0 {
return nil
}
peerPostureChecks := make(map[string]*posture.Checks)
for _, policy := range nmData.Policies {
if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 {
continue
}
if !isPeerInPolicySourceGroupsFromData(nmData, peerID, policy) {
continue
}
for _, checkID := range policy.SourcePostureChecks {
twin := nmData.PostureChecks[checkID]
if twin == nil {
continue
}
peerPostureChecks[checkID] = postureChecksFromTwin(twin)
}
}
return maps.Values(peerPostureChecks)
}
func isPeerInPolicySourceGroupsFromData(nmData *networkmap.NetworkMapData, peerID string, policy *nmdata.Policy) bool {
for _, rule := range policy.Rules {
if rule == nil || !rule.Enabled {
continue
}
for _, groupID := range rule.Sources {
if group := nmData.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) {
return true
}
}
}
return false
}
func postureChecksFromTwin(twin *nmdata.PostureChecks) *posture.Checks {
checks := &posture.Checks{ID: twin.ID}
if twin.Checks.ProcessCheck != nil {
processes := make([]posture.Process, 0, len(twin.Checks.ProcessCheck.Processes))
for _, p := range twin.Checks.ProcessCheck.Processes {
processes = append(processes, posture.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
}
checks.Checks.ProcessCheck = &posture.ProcessCheck{Processes: processes}
}
return checks
}
func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion {
if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok {
return perAccount
@@ -325,6 +589,10 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
return nil
}
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.sendUpdateForAffectedPeersFromData(ctx, accountID, peerIDs, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return fmt.Errorf("failed to get account: %v", err)
@@ -340,7 +608,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate))
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validate peers: %v", err)
}
@@ -426,7 +694,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
// the client merges it into Calculate()'s output the same
// way the legacy server did via NetworkMap.Merge.
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToComponentSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -447,7 +715,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s
}
start = time.Now()
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort)
c.metrics.CountToSyncResponseDuration(time.Since(start))
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
@@ -504,7 +772,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
return fmt.Errorf("peer %s doesn't exists in account %s", peerId, accountId)
}
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return fmt.Errorf("failed to get validated peers: %v", err)
}
@@ -564,7 +832,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
// proxyNetworkMap rides the envelope as a ProxyPatch sidecar;
// the client merges it into Calculate()'s output the same
// way the legacy server did via NetworkMap.Merge.
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
Update: update,
@@ -581,7 +849,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
nmap.Merge(proxyNetworkMap)
}
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort)
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
Update: update,
@@ -641,7 +909,11 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
if err != nil {
return nil, nil, nil, nil, 0, err
}
return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil
return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil
}
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.getValidatedPeerWithComponentsFromData(ctx, accountID, peer, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
@@ -651,7 +923,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
c.injectAllProxyPolicies(ctx, account)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return nil, nil, nil, nil, 0, err
}
@@ -688,6 +960,21 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil
}
// getValidatedPeerWithComponentsFromData is the account-free variant of
// GetValidatedPeerWithComponents. The proxy network map fragment is omitted
// like on the other nmdata paths.
func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) {
postureChecks := peerPostureChecksFromData(nmData, peer.ID)
dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, ipv6AllowedPeersFromData(nmData))
components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone)
dnsFwdPort := computeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
return peer, components, nil, postureChecks, dnsFwdPort, nil
}
// BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval.
func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
if len(peerIDs) == 0 {
@@ -794,11 +1081,15 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
}
emptyMap := &types.NetworkMap{
Network: network.Copy(),
Network: types.TwinNetwork(network),
}
return emptyMap, nil, 0, nil
}
if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil {
return c.getValidatedPeerWithMapFromData(ctx, accountID, peerID, nmData)
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
return nil, nil, 0, err
@@ -806,7 +1097,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
c.injectAllProxyPolicies(ctx, account)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return nil, nil, 0, err
}
@@ -846,6 +1137,21 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
return networkMap, postureChecks, dnsFwdPort, nil
}
// getValidatedPeerWithMapFromData is the account-free variant of
// GetValidatedPeerWithMap. The proxy network map fragment is omitted like on
// the other nmdata paths.
func (c *Controller) getValidatedPeerWithMapFromData(ctx context.Context, accountID string, peerID string, nmData *networkmap.NetworkMapData) (*types.NetworkMap, []*posture.Checks, int64, error) {
postureChecks := peerPostureChecksFromData(nmData, peerID)
dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings)
peersCustomZone := networkmap.PeersCustomZone(ctx, accountID, dnsDomain, nmData.Peers, ipv6AllowedPeersFromData(nmData))
networkMap := networkMapFromData(ctx, nmData, peerID, peersCustomZone)
dnsFwdPort := computeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion)
return networkMap, postureChecks, dnsFwdPort, nil
}
// GetDNSDomain returns the configured dnsDomain
func (c *Controller) GetDNSDomain(settings *types.Settings) string {
if settings == nil {
@@ -908,20 +1214,36 @@ func (c *Controller) StartWarmup(ctx context.Context) {
// computeForwarderPort checks if all peers in the account have updated to a specific version or newer.
// If all peers have the required version, it returns the new well-known port (22054), otherwise returns 0.
func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
if len(peers) == 0 {
versions := make([]string, 0, len(peers))
for _, peer := range peers {
versions = append(versions, peer.Meta.WtVersion)
}
return computeForwarderPortFromVersions(versions, requiredVersion)
}
func computeForwarderPortFromData(peers map[string]*nmdata.Peer, requiredVersion string) int64 {
versions := make([]string, 0, len(peers))
for _, peer := range peers {
versions = append(versions, peer.Meta.WtVersion)
}
return computeForwarderPortFromVersions(versions, requiredVersion)
}
func computeForwarderPortFromVersions(wtVersions []string, requiredVersion string) int64 {
if len(wtVersions) == 0 {
return int64(network_map.OldForwarderPort)
}
reqVer := semver.Canonical(requiredVersion)
// Check if all peers have the required version or newer
for _, peer := range peers {
for _, wtVersion := range wtVersions {
// Development version is always supported
if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
if version.IsDevelopmentVersion(wtVersion) {
continue
}
peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
peerVersion := semver.Canonical("v" + wtVersion)
if peerVersion == "" {
// If any peer doesn't have version info, return 0
return int64(network_map.OldForwarderPort)
@@ -1055,7 +1377,7 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
groups[groupID] = group.Peers
}
validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
validatedPeers, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra)
if err != nil {
return nil, err
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/account"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/store"
@@ -61,10 +62,23 @@ func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
Return(true, context.Background(), nil).
AnyTimes()
manager := agentnetwork.NewManager(st, perms, nil, nil)
// Swallow activity events so the mutation paths (create/update/delete)
// are exercisable through the HTTP layer.
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()
manager := agentnetwork.NewManager(st, perms, accounts, nil)
h := &handler{manager: manager}
router := mux.NewRouter()
router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST")
router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET")
router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT")
h.addPolicyEndpoints(router)
h.addConsumptionEndpoints(router)
h.addBudgetRuleEndpoints(router)

View File

@@ -1,7 +1,9 @@
package handlers
import (
"encoding/json"
"math"
nethttp "net/http"
"testing"
"github.com/stretchr/testify/assert"
@@ -51,3 +53,50 @@ func TestValidate_ModelRates(t *testing.T) {
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
}
}
// TestProviderHandler_UpdateReplacesFullState pins the update contract shared
// with the other PUT endpoints: the request replaces the provider's mutable
// state, so optional fields absent from the JSON land as their zero values.
// The two exceptions are server-side: the api_key (a secret — omitted means
// "not rotated") and the session keypair, both preserved by the manager. The
// identity headers stay on the wire as explicit empty strings so a cleared
// value round-trips.
func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
f := newAgentNetworkHandlerFixture(t)
create := `{
"provider_id": "openai_api",
"name": "openai",
"upstream_url": "https://api.openai.com",
"api_key": "sk-test",
"enabled": true,
"metadata_disabled": true,
"skip_tls_verification": true,
"extra_values": {"x-portkey-config": "pc-prod-3f2a"},
"identity_header_user_id": "x-bf-dim-netbird_user_id",
"models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}]
}`
rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create)
require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String())
var created api.AgentNetworkProvider
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
// Minimal update: only the required fields, no api_key. Everything
// optional must land as its zero value.
update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://api.openai.com", "enabled": true}`
rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update)
require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String())
var updated api.AgentNetworkProvider
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated))
assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply")
assert.True(t, updated.Enabled, "sent field must apply")
assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state")
assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false")
assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared")
assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire")
assert.Empty(t, updated.Models, "omitted models must be cleared")
assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`,
"cleared identity header must round-trip as an explicit empty string")
}

View File

@@ -2,7 +2,6 @@ package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/gorilla/mux"
@@ -11,19 +10,20 @@ import (
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status"
)
// addSettingsEndpoints registers the Agent Network settings routes. The
// settings row is bootstrapped server-side on first provider create; GET reads
// it and PUT updates the mutable collection toggles (cluster/subdomain stay
// immutable).
// settings row is bootstrapped server-side on first provider create or on the
// first PUT carrying a cluster; GET reads it and PUT applies a partial update
// of the mutable collection toggles (cluster/subdomain stay immutable).
func (h *handler) addSettingsEndpoints(router *mux.Router) {
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
}
// updateSettings applies the collection toggles to the account's settings row.
// updateSettings replaces the mutable settings fields on the account's row.
// A request carrying a cluster bootstraps the row when the account doesn't
// have one yet.
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -48,11 +48,9 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
}
// getSettings returns the account's agent-network settings. The settings
// row is bootstrapped on first provider create, so freshly-onboarded
// accounts have nothing to read. Rather than 404-ing in that case (which
// the dashboard would have to special-case), return a JSON null with 200
// so consumers can branch on the body alone.
// getSettings returns the account's agent-network settings. Accounts that
// haven't been bootstrapped yet read as the defaults with an empty cluster,
// subdomain and endpoint; the manager synthesises that view.
func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -62,11 +60,6 @@ func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
if err != nil {
var sErr *status.Error
if errors.As(err, &sErr) && sErr.Type() == status.NotFound {
util.WriteJSONObject(r.Context(), w, nil)
return
}
util.WriteError(r.Context(), err, w)
return
}

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