Compare commits

...

26 Commits

Author SHA1 Message Date
Theodor S. Midtlien
bbf326e22c Fix up comments and improve readability 2026-07-25 20:51:29 +02:00
Theodor S. Midtlien
11642aeb44 Make active profile name available for other owners in status and list 2026-07-25 20:04:01 +02:00
Theodor S. Midtlien
14917dbc22 Refactor owner to be daemon-wide 2026-07-25 18:31:39 +02:00
Theodor S. Midtlien
ae9ee15501 Relax profile command ACLs 2026-07-25 16:09:22 +02:00
Theodor S. Midtlien
cd98648a67 Add elevation for dangerous ssh flags 2026-07-24 21:30:10 +02:00
Theodor S. Midtlien
0bb73b3730 Add warning log for tcp json socket 2026-07-24 19:39:58 +02:00
Theodor S. Midtlien
5e07a0c27b Add migration for legacy profile ownership 2026-07-24 15:31:57 +02:00
Theodor S. Midtlien
0137876618 Clean up comments 2026-07-24 14:57:21 +02:00
Theodor S. Midtlien
57f9cbe5ff Add migration from legacy tcp to named pipes 2026-07-24 10:58:29 +02:00
Theodor S. Midtlien
f60ac9e746 Wire up json socket as a named pipe with metdata exchange to daemon 2026-07-23 20:27:06 +02:00
Theodor S. Midtlien
2f84aa3d20 Remove PID from Identity 2026-07-23 19:43:58 +02:00
Theodor S. Midtlien
4de39a80f8 Clean up windows impersonation/SDDL and some comments 2026-07-23 19:35:22 +02:00
Theodor S. Midtlien
5d9ef0123f Add impersonateNamedPipeClient 2026-07-23 16:05:27 +02:00
Theodor S. Midtlien
27afbd7952 WIP: acl interceptor and named pipe ui 2026-07-23 14:14:42 +02:00
Zoltan Papp
a12a3e4603 [client] Keep session deadline visible across reconnects (#6847)
## Describe your changes

The tray session row flapped on every transient engine restart and went
blank at the exact moment the session expired:

- Watcher.Close zeroed the server-scoped recorder deadline on every
engine restart (sleep/wake, network change, stream errors), hiding the
tray row until the next successful login re-seeded it. Close now only
stops the timers; the client run loop clears the recorder when it exits
for real.
- Status.GetSessionExpiresAt masked past deadlines as zero, so the
status snapshot dropped the field right when the UI should have shown
"session expired". The getter now returns the raw value and the tray
renders past deadlines as "Session expired — sign in again", routing the
click to the login flow.
- The watcher rejected recently-expired deadlines (30s skew) delivered
by login/sync, clearing the recorder instead of surfacing the expiry.
Deadlines up to 30 days past are now recorded as expired; only older
values are rejected as garbage.

## 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/6847"><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 Codesmith"
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=1787220767&installation_model_id=427504&pr_number=6847&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6847&signature=77977ad47c5eda51e1d76e59bc736203b4592462bf94076811a20ea0f7f23aee"><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 Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</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**
* Tray “session expires” and countdown now accurately reflect expired
sessions and remaining time near boundary moments (including improved
rounding).
* **Bug Fixes**
* Recently expired deadlines are retained for correct status reporting,
while very old past deadlines are rejected and cleared.
* Session expiry “expires at” is preserved when the session watcher
closes during reconnect scenarios.
* Clicking an already-expired session in the tray now routes the user to
the login screen.
* **Tests**
* Updated and expanded coverage for recent/ancient past handling and
watcher close behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 15:22:16 +02:00
Riccardo Manfrin
dc89b471fa [client] checks/enforce MDM disableAutostart on every GUI launch, not just fresh installs (#6782)
## Describe your changes

`applyAutostartDefault` gated all MDM enforcement behind the one-time
`AutostartInitialized` marker, so MDM `disableAutostart` only affected
fresh installs — a policy pushed after autostart had been enabled could
not
revoke the OS login-item. 

The PR adds follow-up MDM enforcements at the top of
the function: if at any time MDM sets `disableAutostart=true` and the OS
registration is
present, force `SetEnabled(false)` to align it.

Trade-off: once the admin lifts the policy, autostart stays off until
the user re-toggles
in Settings — consistent with "MDM always wins" behavior of the other
managed keys.

## Issue ticket number and link

Follow-up to PR https://github.com/netbirdio/netbird/pull/6738
(introduced
the `disableAutostart` MDM key with fresh-install-only semantics).

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

- [x] I added/updated documentation for this change
- [ ] 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/855>


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

- **New Features**
- Added an administrative policy to disable client autostart (“Disable
Autostart”).
- Added support for configuring this policy via macOS MDM, Windows Group
Policy (ADMX/ADML), and registry settings.
- When enforced, the client prevents new autostart registration on fresh
installs and removes existing autostart on the next GUI launch, keeping
it disabled until the policy is lifted.

- **Bug Fixes**
- Improved enforcement logic for managed autostart defaults so policy
state is applied consistently during startup and first-run setup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 11:34:13 +02:00
Zoltan Papp
0e520ee9f5 [client] Copy trustedproxy package into Docker build context (#6851)
## 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/6851"><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 Codesmith"
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=1787231318&installation_model_id=427504&pr_number=6851&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6851&signature=dcbd1f845098d270ded2fec67182952868fbcb9f5838ef1fe5f3b6f6406f01d4"><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 Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</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**
* Updated the build process to include trusted proxy configuration in
the application image.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 16:21:49 +02:00
Zoltan Papp
9620890b65 [client] Always connect on profile selection except in manage profiles (#6838)
Selecting a profile from the header dropdown or the tray submenu now
always brings the connection up after the switch, regardless of the
previous daemon state. Switching from the manage-profiles screen
(including profile creation) never connects, leaving a chance to adjust
the management URL first.

## Describe your changes

## Issue ticket number and link

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

### 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/6838"><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 Codesmith"
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=1787157550&installation_model_id=427504&pr_number=6838&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6838&signature=8fe9c5f0779df46c76b4135a373591762eb1498d890ff6996626fb75d6433e0a"><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 Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</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 the ability to switch profiles without automatically
establishing a connection.
* Existing profile switching continues to connect when appropriate,
while safely handling active or pending connections during the switch.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 15:28:11 +02:00
Eduard Gert
69c35e31b4 [client] Fix browser dialog not closing on renew session flow (#6745)
## 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

* **Bug Fixes**
* SSO browser-login popups now open centered on the display where the
cursor is located, and they recenter correctly on subsequent opens.
* Programmatic cleanup no longer triggers “login canceled” behavior;
cancel is emitted only when the user closes the active popup.
* **New Features**
* Added a streamlined “close renewal flow” action that tears down the
session-renewal UI by closing both the login and session-expiration
popups.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:01:31 +02:00
Zoltan Papp
b6cd8944b1 [client] Fix nil context panic in iOS dynamic route resolver (#6848)
## Describe your changes

getIPsFromResolver passed a nil context to ExchangeWithFallback, which
net.Dialer.DialContext rejects with panic("nil context"). On iOS this
crashed the whole network extension (SIGABRT) ~2 seconds after connect
whenever the network map contained a domain-based (dynamic) route, as
the resolver goroutine panicked on its first DNS query.

Passing nil used to be a documented input of ExchangeWithFallback ("If
the passed context is nil, this will use Exchange instead of
ExchangeContext") since #3632. 9ed2e2a5b (#5971) removed the nil-context
branch, but this iOS-only caller was not updated — it never fails CI
since route_ios.go only builds with GOOS=ios. Broken since v0.71.1.

Pass a context bounded by the existing dialTimeout instead, matching the
dnsinterceptor pattern (context.Background() + timeout).

Captured panic (netbird.err):
  panic: nil context
  net.(*Dialer).DialContext -> miekg/dns ExchangeContext ->
nbdns.ExchangeWithFallback(nil, ...) ->
dynamic.(*Route).getIPsFromResolver
  route_ios.go:35


## 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/6848"><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 Codesmith"
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=1787223752&installation_model_id=427504&pr_number=6848&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6848&signature=881d3b30aa4bd15001562ad417ff946b70c7c108856a1b3808b776c12b7022a9"><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 Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</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**
* Fixed iOS dynamic DNS resolution by ensuring DNS queries use a proper
resolver context instead of a nil one.
* Improved DNS resolution reliability by propagating
cancellation/timeouts through all domain IP lookups, including fallback
system resolver queries.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 13:37:29 +02:00
Zoltan Papp
6fc05efa6c [client] Disconnect daemon on GUI quit via async Down (#6796)
The tray Quit menu now disconnects the daemon before exiting instead of
only tearing down the GUI. A new DownAsync RPC lets the daemon start the
teardown and return immediately: beginDown cancels the connection under
the mutex (so it cannot reconnect), then finishDown (the retry-goroutine
wait and status reset) runs on a background goroutine. handleQuit aborts
any in-flight profile switch first (so a queued Up cannot reconnect
during teardown) and calls DownAsync so quitting never blocks on the
engine shutdown.

## Describe your changes

## Issue ticket number and link

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

### 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/6796"><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 Codesmith"
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=1786785261&installation_id=146802194&pr_number=6796&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6796&signature=4fc95ebfed320240170c8318a2bc8750acfa5ad131454e3011c43bc8a5a09ed1"><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 Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</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 shutdown reliability by continuing teardown even when
stopping the service fails (stop errors are logged but not returned).
* Refined connection shutdown to return “service not up” errors directly
for clearer, more immediate RPC behavior.
* Prevented shutdown hangs by making the tray Quit disconnect
time-bounded (5 seconds).
* Ensured any in-flight profile switch is cancelled before exiting, with
quit serialized to avoid races.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 13:34:30 +02:00
Eduard Gert
3cda14d7f2 [client] Use menu bar wording on macOS welcome screen (#6810)
## Describe your changes

The post-install welcome step said "Look for NetBird in your tray" on
every OS, but macOS has no system tray — the icon sits in the menu bar.
The tray wording stays correct on Windows and Linux.

- Add `welcome.titleMac` and `welcome.descriptionMac` (following the
existing `settings.advanced.interfaceName.errorMac` key convention) to
`en` and all nine translated bundles (`de`, `es`, `fr`, `hu`, `it`,
`ja`, `pt`, `ru`, `zh-CN`), each using that language's Apple term for
the menu bar (Menüleiste, barra de menús, barre des menus, menüsor,
barra dei menu, メニューバー, barra de menus, строка меню, 菜单栏). The `ja`
bundle landed on main (#6790) after the initial commit and was covered
after merging main back in.
- `WelcomeStepTray.tsx` picks the key via `isMacOS()`, which it already
uses to choose the per-OS screenshot.

Verified: `go test ./client/ui/i18n/...`, `tsc --noEmit`, `eslint`, and
`prettier --check` all pass; key set and placement verified identical
across all ten bundles. Not visually verified in the running app
(headless session) — the welcome dialog only shows on first launch.

## Issue ticket number and link

Fixes NET-1411

## 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)
- [x] 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 (UI copy fix only;
no behavior, API, or configuration change)

### Docs PR URL (required if "docs added" is checked)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6810"><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 Codesmith"
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=1786865844&installation_id=146802194&pr_number=6810&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6810&signature=c18b58a9624d319bff3010aeb92c7b11cc7e99f82ffa5e15a32053500d620af4"><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 Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</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 macOS-specific onboarding text that directs users to find
NetBird in the menu bar.
* Updated localized welcome content across supported languages, while
retaining platform-specific tray guidance where applicable.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 11:26:27 +02:00
Eduard Gert
d9392fdbb8 [client] Clarify outdated NetBird client overlay (#6718)
## Describe your changes
Add more information about the current client and GUI versions if the
user is running an older client.
Update the URL to download the latest RC if the user is running any RC
build.

<img width="348" height="497" alt="CleanShot 2026-07-10 at 15 11 54"
src="https://github.com/user-attachments/assets/2579f5f3-5056-4184-96bb-9f93d5860328"
/>

## Issue ticket number and link

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

### 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-07-21 11:26:16 +02:00
Maycon Santos
82fdfa84b8 [proxy] match Bedrock provider models against the normalized request model (#6773)
## Describe your changes

Native AWS Bedrock requests carry the model in the URL path as a
cross-region inference-profile id (e.g.
`us.anthropic.claude-haiku-4-5`). The request parser normalizes that to
the catalog key (`anthropic.claude-haiku-4-5`) before the router runs,
but the router matched it against the operator's registered provider
models with exact string equality. So a Bedrock provider registered with
the id Bedrock actually uses (`us.anthropic…`) never matched a
normalized request → the request denied with
`llm_policy.model_not_routable` ("no provider configured for model …").
Only a provider registered with the already-stripped catalog id worked,
which is not how Bedrock ids appear.

Fix: introduce a single shared `llm.NormalizeBedrockModel` (the same
ARN/region-prefix/version-suffix stripping the parser already does) and,
in the router's `routeClaimsModel`, normalize a **Bedrock** route's
candidate models before comparing. Now a Bedrock provider registered
with either the raw inference-profile id or the normalized catalog id
matches the request. Non-Bedrock routes keep exact matching.

Surfaced by the new native-Bedrock e2e (`WireBedrock`,
`/model/{id}/invoke`); the old e2e used the Anthropic body shape, which
never normalized either side and so hid this.

The request parser keeps its own identical normalizer for now;
de-duplicating it onto `llm.NormalizeBedrockModel` is a trivial
follow-up.

## Issue ticket number and link

N/A — follow-up to the Agent Network Bedrock support / model-allowlist
work.

## Stack

<!-- branch-stack -->

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

Internal routing correctness fix; no user-facing surface change.

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

## Tests

- `proxy/internal/llm`: `NormalizeBedrockModel` unit cases (region
prefixes, version suffixes, ARN).
- `proxy/internal/middleware/builtin/llm_router`: `routeClaimsModel`
matches a Bedrock route registered with the raw `us.anthropic…` id
against a normalized request model; non-Bedrock routes still match
exactly.

Note: full through-tunnel e2e verification of this (the native-Bedrock
`TestProvidersMatrix/bedrock`) also needs the DNS lazy-connection
warm-up (separate PR) to get the client past the proxy-peer gate; they
converge once both land.


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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved Amazon Bedrock model matching across ARN formats, regional
prefixes, and version or throughput suffixes.
* Bedrock routes now correctly match equivalent model identifiers even
when requests and route configurations use different formats.
  * Non-Bedrock model matching remains exact.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 10:10:12 +02:00
Zoltan Papp
ca80e49aa0 [client] Refresh WireGuard stats in mobile debug bundles (#6814)
The iOS and Android DebugBundle paths built GeneratorDependencies
without setting RefreshStatus, so the bundle's status.txt read the
cached peer state instead of live WireGuard interface stats. When the
periodic health probe had not run yet, connected relayed peers showed
"handshake: -" and "0 B/0 B" even though the interface was passing
traffic.

Wire RefreshStatus to RunHealthProbes on both platforms, matching the
desktop daemon path in client/server/debug.go. The engine reference is
already available in the cc.Engine() block used for client metrics.

## 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/6814"><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 Codesmith"
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=1786881440&installation_id=146802194&pr_number=6814&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6814&signature=c07cbcef1028a366312f7743e6479f2fddafd568aac498ab95828a372ac6843c"><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 Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</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 debug bundle generation on Android and iOS by refreshing
connection health status before collecting diagnostic information.
* Ensured debug bundles include more current health-related data for
troubleshooting.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 09:25:33 +02:00
Zoltan Papp
51f17bf919 [client] Update wails to v3.0.0-alpha2.117 (#6837)
## Describe your changes

Update wails to v3.0.0-alpha2.117

## Issue ticket number and link

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

### 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/6837"><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 Codesmith"
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=1787156447&installation_model_id=427504&pr_number=6837&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6837&signature=600f7cb9ecda9ee75b7fe841ed81149507d00dbf6323c61aa92ac2acdaea2487"><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 Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>/codesmith</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

* **Chores**
  * Updated the application framework dependency to a newer release.
  * Removed an obsolete supporting dependency requirement.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-20 21:12:22 +02:00
120 changed files with 4957 additions and 687 deletions

View File

@@ -237,7 +237,7 @@ task dev
Pass daemon flags after `--`:
```
task dev -- --daemon-addr=tcp://127.0.0.1:41731
task dev -- --daemon-addr=npipe://netbird
```
Production build (frontend assets embedded into the binary, output in `client/ui/bin/`):

View File

@@ -247,6 +247,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
deps.SyncResponse = resp
if e := cc.Engine(); e != nil {
deps.RefreshStatus = func() {
e.RunHealthProbes(context.Background(), true)
}
if cm := e.GetClientMetrics(); cm != nil {
deps.ClientMetrics = cm
}

View File

@@ -145,7 +145,7 @@ func (pm *ProfileManager) SwitchProfile(id string) error {
// AddProfile creates a new profile
func (pm *ProfileManager) AddProfile(profileName string) error {
// Use ServiceManager (creates profile in profiles/ directory)
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername, nil)
if err != nil {
return fmt.Errorf("failed to add profile: %w", err)
}

58
client/cmd/elevate.go Normal file
View File

@@ -0,0 +1,58 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// SetSSHConfigCmdName is the hidden subcommand an elevated process runs to apply
// the privileged SSH settings.
const SetSSHConfigCmdName = "set-ssh-config"
// sshConfigElevated is set by runInDaemonMode once the dangerous SSH settings
// have been applied by an elevated helper, so the (unprivileged) up flow does
// not re-send them and re-trip the daemon gate.
var sshConfigElevated bool
// wantsDangerousSSH reports whether this invocation is trying to ENABLE SSH root
// login or DISABLE SSH authentication.
func wantsDangerousSSH(cmd *cobra.Command) bool {
return (cmd.Flags().Changed(enableSSHRootFlag) && enableSSHRoot) ||
(cmd.Flags().Changed(disableSSHAuthFlag) && disableSSHAuth)
}
// buildSetSSHConfigArgs builds the argument list for an elevated
// `netbird set-ssh-config` invocation.
func buildSetSSHConfigArgs(profileName, username string, enableSSHRoot, disableSSHAuth *bool, daemonAddr string) []string {
args := []string{SetSSHConfigCmdName}
if profileName != "" {
args = append(args, "--profile", profileName)
}
if username != "" {
args = append(args, "--username", username)
}
if enableSSHRoot != nil && *enableSSHRoot {
args = append(args, "--"+enableSSHRootFlag)
}
if disableSSHAuth != nil && *disableSSHAuth {
args = append(args, "--"+disableSSHAuthFlag)
}
if daemonAddr != "" {
args = append(args, "--daemon-addr", daemonAddr)
}
return args
}
// ElevateSSHConfig re-runs the current executable's `set-ssh-config` command with
// root/administrator privileges via the platform's prompt (pkexec/UAC/osascript),
// so the elevated process connects to the daemon with a privileged identity and
// the daemon's requirePrivilegedForDangerousSSH gate passes.
func ElevateSSHConfig(profileName, username string, enableSSHRoot, disableSSHAuth *bool) error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locate executable for elevation: %w", err)
}
return runElevated(exe, buildSetSSHConfigArgs(profileName, username, enableSSHRoot, disableSSHAuth, daemonAddr))
}

View File

@@ -0,0 +1,16 @@
//go:build darwin
package cmd
import (
"fmt"
"os"
)
// isProcessPrivileged reports whether the current process runs as root.
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
// TODO(ssh-elevation): implement the osascript admin prompt.
func runElevated(_ string, _ []string) error {
return fmt.Errorf("automatic privilege elevation is not yet implemented on macOS, re-run with sudo")
}

View File

@@ -0,0 +1,38 @@
//go:build linux
package cmd
import (
"fmt"
"os"
"os/exec"
"strings"
)
// isProcessPrivileged reports whether the current process runs as root.
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
// runElevated re-runs exe with args as root. On a graphical session it uses
// pkexec, which drives the desktop's polkit authentication agent (GUI prompt).
func runElevated(exe string, args []string) error {
if !hasGraphicalSession() {
return fmt.Errorf("cannot request privilege elevation without a graphical session. re-run as root: sudo %s %s", exe, strings.Join(args, " "))
}
pkexec, err := exec.LookPath("pkexec")
if err != nil {
return fmt.Errorf("pkexec not found for privilege elevation, re-run as root: sudo %s %s", exe, strings.Join(args, " "))
}
c := exec.Command(pkexec, append([]string{exe}, args...)...)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := c.Run(); err != nil {
return fmt.Errorf("elevated %s failed (elevation cancelled or denied?): %w", SetSSHConfigCmdName, err)
}
return nil
}
// hasGraphicalSession heuristically reports whether a desktop session is present
// that polkit can prompt in.
func hasGraphicalSession() bool {
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
}

View File

@@ -0,0 +1,17 @@
//go:build !linux && !darwin && !windows
package cmd
import (
"fmt"
"os"
"strings"
)
// isProcessPrivileged reports whether the current process runs as root.
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
// runElevated has no automatic elevation mechanism on these platforms
func runElevated(exe string, args []string) error {
return fmt.Errorf("automatic privilege elevation is not supported on this platform, re-run as root: sudo %s %s", exe, strings.Join(args, " "))
}

View File

@@ -0,0 +1,99 @@
//go:build !ios && !android
package cmd
import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildSetSSHConfigArgs(t *testing.T) {
tr, fa := true, false
t.Run("enable root only, with daemon-addr", func(t *testing.T) {
got := buildSetSSHConfigArgs("prof", "alice", &tr, nil, "unix:///x.sock")
assert.Equal(t, []string{
SetSSHConfigCmdName, "--profile", "prof", "--username", "alice",
"--" + enableSSHRootFlag, "--daemon-addr", "unix:///x.sock",
}, got)
})
t.Run("disable auth only, no daemon-addr", func(t *testing.T) {
got := buildSetSSHConfigArgs("prof", "alice", nil, &tr, "")
assert.Equal(t, []string{
SetSSHConfigCmdName, "--profile", "prof", "--username", "alice",
"--" + disableSSHAuthFlag,
}, got)
})
t.Run("false pointers omit the flags", func(t *testing.T) {
got := buildSetSSHConfigArgs("", "", &fa, &fa, "")
assert.Equal(t, []string{SetSSHConfigCmdName}, got)
})
t.Run("both enabled", func(t *testing.T) {
got := buildSetSSHConfigArgs("p", "u", &tr, &tr, "")
assert.Equal(t, []string{
SetSSHConfigCmdName, "--profile", "p", "--username", "u",
"--" + enableSSHRootFlag, "--" + disableSSHAuthFlag,
}, got)
})
}
func TestBuildSetSSHConfigRequest(t *testing.T) {
tr := true
req := buildSetSSHConfigRequest("p", "u", &tr, nil)
assert.Equal(t, "p", req.ProfileName)
assert.Equal(t, "u", req.Username)
if assert.NotNil(t, req.EnableSSHRoot) {
assert.True(t, *req.EnableSSHRoot)
}
assert.Nil(t, req.DisableSSHAuth, "an unset flag must leave the daemon value untouched")
}
func TestWantsDangerousSSH(t *testing.T) {
origRoot, origAuth := enableSSHRoot, disableSSHAuth
t.Cleanup(func() { enableSSHRoot, disableSSHAuth = origRoot, origAuth })
newCmd := func() *cobra.Command {
enableSSHRoot, disableSSHAuth = false, false
c := &cobra.Command{Use: "x"}
c.Flags().BoolVar(&enableSSHRoot, enableSSHRootFlag, false, "")
c.Flags().BoolVar(&disableSSHAuth, disableSSHAuthFlag, false, "")
return c
}
// wantsDangerousSSH fires only in the privileged direction.
t.Run("enable root true is dangerous", func(t *testing.T) {
c := newCmd()
require.NoError(t, c.Flags().Set(enableSSHRootFlag, "true"))
assert.True(t, wantsDangerousSSH(c))
})
t.Run("enable root false is not dangerous", func(t *testing.T) {
c := newCmd()
require.NoError(t, c.Flags().Set(enableSSHRootFlag, "false"))
assert.False(t, wantsDangerousSSH(c))
})
t.Run("disable auth true is dangerous", func(t *testing.T) {
c := newCmd()
require.NoError(t, c.Flags().Set(disableSSHAuthFlag, "true"))
assert.True(t, wantsDangerousSSH(c))
})
t.Run("disable auth false is not dangerous", func(t *testing.T) {
c := newCmd()
require.NoError(t, c.Flags().Set(disableSSHAuthFlag, "false"))
assert.False(t, wantsDangerousSSH(c))
})
t.Run("nothing changed is not dangerous", func(t *testing.T) {
c := newCmd()
assert.False(t, wantsDangerousSSH(c))
})
}

View File

@@ -0,0 +1,23 @@
//go:build windows
package cmd
import (
"fmt"
"golang.org/x/sys/windows"
)
// isProcessPrivileged reports whether the current process token is elevated
// (running as administrator / LocalSystem).
func isProcessPrivileged() bool {
return windows.GetCurrentProcessToken().IsElevated()
}
// runElevated should re-run exe with args elevated via a UAC prompt
// (ShellExecuteEx with the "runas" verb).
//
// TODO(ssh-elevation): implement ShellExecuteEx("runas") + wait for the child.
func runElevated(_ string, _ []string) error {
return fmt.Errorf("automatic privilege elevation is not yet implemented on Windows, re-run netbird as administrator")
}

116
client/cmd/owner.go Normal file
View File

@@ -0,0 +1,116 @@
package cmd
import (
"context"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
var ownerCmd = &cobra.Command{
Use: "owner",
Short: "Manage who may control the NetBird daemon",
Long: `Manage the daemon-wide owners.
Owners are enforced on the daemon and stored in the service parameters. All owners
may control the daemon and use the shared default profile (plus root/administrator),
every other profile stays isolated to the user that created it. An unowned daemon
is claimed by the first caller (trust-on-first-use).`,
}
var ownerAddCmd = &cobra.Command{
Use: "add <principal>",
Short: "Add a daemon owner principal",
Long: `Add a daemon-wide owner principal. Principals are typed:
uid:1000 a Unix user ID
gid:1000 a Unix group ID
group:netbird-admins a Unix group name (resolved via NSS/getent)
sid:S-1-5-21-... a Windows user or group SID
Requires root/administrator or an existing owner.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.AddOwner(ctx, &proto.AddOwnerRequest{Principal: args[0]}); err != nil {
return err
}
cmd.Printf("Added daemon owner %q\n", args[0])
return nil
})
},
}
var ownerResetCmd = &cobra.Command{
Use: "reset",
Short: "Clear the daemon owner list (root/administrator only)",
Long: `Clear the daemon-wide owner list, returning the daemon to the unowned
state. The next caller then claims ownership (trust-on-first-use). Requires
root/administrator.`,
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.ResetOwner(ctx, &proto.ResetOwnerRequest{}); err != nil {
return err
}
cmd.Println("Daemon owner list cleared, the next caller will claim ownership")
return nil
})
},
}
var ownerShareCmd = &cobra.Command{
Use: "share",
Short: "Mark the daemon shared (any local user may control it)",
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: true}); err != nil {
return err
}
cmd.Println("Daemon is now shared with all local users")
return nil
})
},
}
var ownerUnshareCmd = &cobra.Command{
Use: "unshare",
Short: "Stop sharing the daemon (restrict to its owners)",
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: false}); err != nil {
return err
}
cmd.Println("Daemon is no longer shared")
return nil
})
},
}
// withDaemon runs fn with a connected daemon client, handling setup and teardown.
func withDaemon(cmd *cobra.Command, fn func(context.Context, proto.DaemonServiceClient) error) error {
SetFlagsFromEnvVars(rootCmd)
cmd.SetOut(cmd.OutOrStdout())
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
log.Errorf("failed initializing log %v", err)
return err
}
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
defer cancel()
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
log.Errorf("failed to connect to service CLI interface %v", err)
return err
}
defer func() {
if cerr := conn.Close(); cerr != nil {
log.Debugf("close daemon connection: %v", cerr)
}
}()
return fn(ctx, proto.NewDaemonServiceClient(conn))
}

View File

@@ -117,10 +117,12 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
} else {
fmt.Fprintln(tw, "NAME\tACTIVE")
}
anyActive := false
for _, profile := range resp.Profiles {
marker := ""
if profile.IsActive {
marker = "✓"
anyActive = true
}
name := profilemanager.StripCtrlChars(profile.Name)
id := profilemanager.ID(profile.Id)
@@ -130,7 +132,19 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
fmt.Fprintf(tw, "%s\t%s\n", name, marker)
}
}
return tw.Flush()
if err := tw.Flush(); err != nil {
return err
}
// None of the caller's profiles is active: another user may hold the daemon.
// Surface it so the empty ACTIVE column is not mistaken for "nothing active".
if !anyActive {
if active, aerr := daemonClient.GetActiveProfile(cmd.Context(), &proto.GetActiveProfileRequest{}); aerr == nil && active.GetUsername() != "" && !usernamesMatch(active.GetUsername(), currUser.Username) {
cmd.Printf("\nActive profile belongs to another user: %s (user %s)\n",
profilemanager.StripCtrlChars(active.GetProfileName()), active.GetUsername())
}
}
return nil
}
func addProfileFunc(cmd *cobra.Command, args []string) error {

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"io/fs"
"net"
"os"
"os/signal"
"path"
@@ -143,10 +144,10 @@ func init() {
defaultDaemonAddr := "unix:///var/run/netbird.sock"
if runtime.GOOS == "windows" {
defaultDaemonAddr = "tcp://127.0.0.1:41731"
defaultDaemonAddr = windowsPipeDaemonAddr
}
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
@@ -172,6 +173,11 @@ func init() {
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)
rootCmd.AddCommand(ownerCmd)
ownerCmd.AddCommand(ownerAddCmd, ownerResetCmd, ownerShareCmd, ownerUnshareCmd)
rootCmd.AddCommand(setSSHConfigCmd)
networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
@@ -264,17 +270,32 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
return prefix + upper
}
// daemonDialTarget returns the gRPC dial target and base options for the daemon
// address, handling the npipe scheme (Windows named pipe, via a context dialer)
// and unix/tcp.
func daemonDialTarget(addr string) (string, []grpc.DialOption) {
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
target := strings.TrimPrefix(addr, "tcp://")
if strings.HasPrefix(addr, "npipe://") {
path := pipePath(strings.TrimPrefix(addr, "npipe://"))
opts = append(opts, grpc.WithContextDialer(func(dialCtx context.Context, _ string) (net.Conn, error) {
return dialNamedPipe(dialCtx, path)
}))
target = "passthrough:///netbird-daemon-pipe"
}
return target, opts
}
// DialClientGRPCServer returns client connection to the daemon server.
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
func DialClientGRPCServer(ctx context.Context, addr string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
return grpc.DialContext(
ctx,
strings.TrimPrefix(addr, "tcp://"),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
target, dialOpts := daemonDialTarget(addr)
dialOpts = append(dialOpts, grpc.WithBlock())
dialOpts = append(dialOpts, opts...)
return grpc.DialContext(ctx, target, dialOpts...)
}
// WithBackOff execute function in backoff cycle.

View File

@@ -30,6 +30,12 @@ var (
serviceEnvVars []string
jsonSocket string
enableJSONSocket bool
// owners seeds the daemon-wide owner set at install time (--owner). At runtime
// the daemon reads and writes owners in service.json directly.
owners []string
// daemonShared carries the persisted daemon shared flag across
// install/reconfigure round-trips (set at runtime via `netbird owner share`).
daemonShared bool
)
type program struct {
@@ -54,7 +60,8 @@ func init() {
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket")
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp|npipe]://[path|host:port|name]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
serviceCmd.PersistentFlags().StringSliceVar(&owners, "owner", nil, "Principal(s) allowed to control the daemon and its default profile: uid:1000, gid:1000, group:netbird-admins (NSS), or sid:S-1-5-... (Windows). Repeatable. Other profiles stay isolated per user. To persist: netbird service install --owner uid:1000")
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
serviceEnvDesc := `Sets extra environment variables for the service. ` +

View File

@@ -5,6 +5,7 @@ package cmd
import (
"context"
"fmt"
"runtime"
"time"
"github.com/kardianos/service"
@@ -13,12 +14,32 @@ import (
"github.com/spf13/cobra"
"google.golang.org/grpc"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/util"
)
// daemonServerOptions installs peer-identity transport credentials and the
// authorization interceptor on the daemon ipc if supported.
func daemonServerOptions(network string, interceptor *ipcauth.Interceptor) []grpc.ServerOption {
creds := ipcauth.NewTransportCredentials()
if creds == nil {
log.Warnf("daemon ipc has no peer-identity primitive on %s, per-caller authorization is disabled", runtime.GOOS)
return nil
}
if network == "tcp" {
log.Warnf("daemon is listening on TCP (%s), peer identity cannot be authenticated over TCP, per-caller authorization is disabled", daemonAddr)
return nil
}
return []grpc.ServerOption{
grpc.Creds(creds),
grpc.ChainUnaryInterceptor(interceptor.UnaryServerInterceptor()),
grpc.ChainStreamInterceptor(interceptor.StreamServerInterceptor()),
}
}
func validateJSONSocketFlags() error {
if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket {
return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway")
@@ -37,8 +58,28 @@ func (p *program) Start(svc service.Service) error {
// Collect static system and platform information
system.UpdateStaticInfoAsync()
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
p.serv = grpc.NewServer()
// A daemon installed before named-pipe support uses the old loopback-TCP
// address as the daemon address. We migrate to a named pipe so an
// upgraded daemon enforces per-caller authorization instead of silently
// running on identity-less TCP.
if migrated, ok := migrateLegacyDaemonAddr(daemonAddr); ok {
log.Infof("legacy daemon address %q predates named-pipe support. listening on %q so per-caller authorization is enforced", daemonAddr, migrated)
daemonAddr = migrated
}
network, _, err := parseListenAddress(daemonAddr)
if err != nil {
return fmt.Errorf("parse daemon address: %w", err)
}
// Owner-authorization interceptor. The ConfigAdapter is a lazy bridge: the
// gRPC server is built before the daemon server instance exists, so we set
// the real policy backend below once serverInstance is created.
ownerAdapter := &ipcauth.ConfigAdapter{}
authInterceptor := ipcauth.NewInterceptor(ownerAdapter, ipcauth.NewDefaultGroupResolver())
// in any case, even if configuration does not exist we run daemon to serve the CLI gRPC API.
p.serv = grpc.NewServer(daemonServerOptions(network, authInterceptor)...)
daemonListener, err := listenOnAddress(daemonAddr)
if err != nil {
@@ -74,9 +115,13 @@ func (p *program) Start(svc service.Service) error {
}
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
// Daemon-wide owners live in service.json (governs the default profile and
// daemon access), wire persistence before serving so owner add / TOFU work.
serverInstance.SetDaemonOwnerStore(daemonOwnerStore{})
if err := serverInstance.Start(); err != nil {
log.Fatalf("failed to start daemon: %v", err)
}
ownerAdapter.SetBackend(serverInstance)
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
p.serverInstanceMu.Lock()
@@ -84,6 +129,7 @@ func (p *program) Start(svc service.Service) error {
p.serverInstanceMu.Unlock()
if jsonListener != nil {
log.Warnf("JSON gateway (--enable-json-socket) re-dials the daemon locally. The HTTP client's identity is forwarded so per-caller authorization still applies, but restrict access to %s appropriately", jsonSocket)
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
log.Fatalf("failed to start daemon JSON server: %v", err)
}

View File

@@ -5,27 +5,62 @@ package cmd
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
func grpcGatewayEndpoint(addr string) string {
return strings.TrimPrefix(addr, "tcp://")
// jsonPeerCtxKey keys the HTTP client's kernel identity in the request context.
type jsonPeerCtxKey struct{}
// jsonConnContext reads the connecting HTTP client's identity from the JSON
// socket and stashes it so it can be forwarded to the daemon. The gateway
// re-dials the daemon as the daemon's own identity, so without this the
// daemon would see every JSON request as privileged.
func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
id, err := ipcauth.ConnIdentity(c)
if err != nil {
log.Warnf("json gateway: cannot read HTTP client identity, requests won't carry it: %v", err)
return ctx
}
return context.WithValue(ctx, jsonPeerCtxKey{}, id)
}
// jsonForwardIdentity injects the stashed HTTP client identity as gRPC metadata
// on the gateway's re-dial to the daemon. The daemon trusts it only because the
// dial arrives as the daemon's own (self/privileged) identity.
func jsonForwardIdentity(ctx context.Context, _ *http.Request) metadata.MD {
id, ok := ctx.Value(jsonPeerCtxKey{}).(ipcauth.Identity)
if !ok {
return nil
}
return ipcauth.ForwardIdentityMetadata(id)
}
func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
mux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
if jsonListener.network == "tcp" {
log.Warnf("json daemon is listening on TCP (%s), peer identity cannot be authenticated over TCP, per-caller authorization is disabled", daemonAddr)
}
mux := runtime.NewServeMux(runtime.WithMetadata(jsonForwardIdentity))
// Lazy client to the daemon, npipe-aware (grpc.NewClient does not connect
// until the first request, so this does not block startup before Serve).
target, opts := daemonDialTarget(daemonEndpoint)
conn, err := grpc.NewClient(target, opts...)
if err != nil {
return fmt.Errorf("create daemon client for JSON gateway: %w", err)
}
if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil {
return err
}
@@ -35,6 +70,7 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
BaseContext: func(net.Listener) context.Context {
return p.ctx
},
ConnContext: jsonConnContext,
}
p.jsonServMu.Lock()

View File

@@ -33,6 +33,15 @@ type serviceParams struct {
DisableNetworks bool `json:"disable_networks,omitempty"`
EnableJSONSocket bool `json:"enable_json_socket,omitempty"`
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
// Owners lists the principals allowed to control this profile over the local
// IPC, as typed strings: "uid:1000", "gid:1000", "group:netbird-admins"
// (Unix, NSS-resolved) or "sid:S-1-5-..." (Windows user or group SID). Empty
// with Shared=false means the profile is owned by nobody yet, until claimed
Owners []string `json:"owners,omitempty"`
// Shared, when true, lets any authenticated local caller control this profile
// (opt-in). Takes precedence over Owners.
Shared bool `json:"shared,omitempty"`
}
// serviceParamsPath returns the path to the service params file.
@@ -40,6 +49,38 @@ func serviceParamsPath() string {
return filepath.Join(configs.StateDir, serviceParamsFile)
}
// daemonOwnerStore persists the daemon-wide owner set in service.json. It
// implements server.DaemonOwnerStore so the daemon can read owners at startup and
// mutate them at runtime (owner add, reset, share, TOFU claim) without server
// importing cmd. Load-modify-write preserves the other service.json fields.
type daemonOwnerStore struct{}
func (daemonOwnerStore) Load() ([]string, bool, error) {
params, err := loadServiceParams()
if err != nil {
return nil, false, err
}
if params == nil {
return nil, false, nil
}
return params.Owners, params.Shared, nil
}
func (daemonOwnerStore) Save(owners []string, shared bool) error {
params, err := loadServiceParams()
if err != nil {
return err
}
if params == nil {
// No service.json yet (daemon started without `service install`). Seed it
// from the running daemon's current parameters so the file stays complete.
params = currentServiceParams()
}
params.Owners = owners
params.Shared = shared
return saveServiceParams(params)
}
// loadServiceParams reads saved service parameters from disk.
// Returns nil with no error if the file does not exist.
func loadServiceParams() (*serviceParams, error) {
@@ -86,6 +127,8 @@ func currentServiceParams() *serviceParams {
EnableCapture: captureEnabled,
DisableNetworks: networksDisabled,
EnableJSONSocket: enableJSONSocket,
Owners: owners,
Shared: daemonShared,
}
if len(serviceEnvVars) > 0 {
@@ -125,6 +168,10 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
daemonAddr = params.DaemonAddr
if migrated, ok := migrateLegacyDaemonAddr(daemonAddr); ok {
cmd.Printf("Migrating saved daemon address %q to %q so per-caller authorization can be enforced\n", daemonAddr, migrated)
daemonAddr = migrated
}
}
if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" {
@@ -165,6 +212,14 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
networksDisabled = params.DisableNetworks
}
// Carry the daemon-wide owner set forward across install/reconfigure so a
// runtime owner add or TOFU claim in service.json is not clobbered. --owner
// overrides.
if !serviceCmd.PersistentFlags().Changed("owner") && len(params.Owners) > 0 {
owners = params.Owners
}
daemonShared = params.Shared
applyServiceEnvParams(cmd, params)
}

View File

@@ -431,9 +431,15 @@ func TestServiceParams_BuildArgsCoversAllFlags(t *testing.T) {
installerFile, err := parser.ParseFile(fset, "service_installer.go", nil, 0)
require.NoError(t, err)
// Fields that are handled outside of buildServiceArguments (env vars go through newSVCConfig).
// Fields that are handled outside of buildServiceArguments.
// - ServiceEnvVars flows through newSVCConfig() EnvVars, not CLI args.
// - Owners/Shared are daemon-wide ownership persisted in service.json and
// read+mutated by the daemon at runtime (owner add / TOFU claim); they are
// deliberately NOT baked into the run args so runtime changes are not lost.
fieldsNotInArgs := map[string]bool{
"ServiceEnvVars": true,
"Owners": true,
"Shared": true,
}
buildFields := extractFuncGlobalRefs(t, installerFile, "buildServiceArguments")

View File

@@ -0,0 +1,20 @@
//go:build !windows
package cmd
import (
"context"
"fmt"
"net"
"runtime"
)
// listenNamedPipe is unsupported off Windows; named pipes are a Windows-only transport.
func listenNamedPipe(string) (net.Listener, error) {
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
}
// dialNamedPipe is unsupported off Windows.
func dialNamedPipe(context.Context, string) (net.Conn, error) {
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
}

View File

@@ -0,0 +1,30 @@
//go:build windows
package cmd
import (
"context"
"net"
"github.com/Microsoft/go-winio"
"golang.org/x/sys/windows"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// listenNamedPipe creates the daemon control named pipe with a permissive,
// local-only SDDL. Any local caller may connect, like Unix socket with 0666.
func listenNamedPipe(path string) (net.Listener, error) {
return winio.ListenPipe(path, &winio.PipeConfig{
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
})
}
// dialNamedPipe connects to the daemon ipc named pipe at SECURITY_IDENTIFICATION.
func dialNamedPipe(ctx context.Context, path string) (net.Conn, error) {
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the
// daemon cannot read the caller's token. Identification lets the daemon
// read its SID/groups without granting it the ability to act as the caller.
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"os"
"runtime"
"strings"
"syscall"
"time"
@@ -14,6 +15,30 @@ import (
log "github.com/sirupsen/logrus"
)
const (
windowsPipeDaemonAddr = "npipe://netbird"
// legacyWindowsDaemonAddr is the loopback-TCP address the Windows daemon used
// before named-pipe support.
legacyWindowsDaemonAddr = "tcp://127.0.0.1:41731"
)
// migrateLegacyDaemonAddr upgrades the pre-named-pipe Windows daemon address to
// the pipe. Existing installs persist daemon addr, so on upgrade the daemon
// would otherwise keep listening on TCP and silently run without IPC
// authorization. Only the exact legacy default is rewritten, while a
// deliberately-chosen custom TCP address is left alone.
func migrateLegacyDaemonAddr(addr string) (string, bool) {
return migrateLegacyDaemonAddrForOS(runtime.GOOS, addr)
}
func migrateLegacyDaemonAddrForOS(goos, addr string) (string, bool) {
if goos == "windows" && addr == legacyWindowsDaemonAddr {
return windowsPipeDaemonAddr, true
}
return addr, false
}
type socketListener struct {
net.Listener
network string
@@ -26,6 +51,15 @@ func listenOnAddress(addr string) (*socketListener, error) {
return nil, err
}
if network == "npipe" {
path := pipePath(address)
listener, err := listenNamedPipe(path)
if err != nil {
return nil, err
}
return &socketListener{Listener: listener, network: network, address: path}, nil
}
if network == "unix" {
removeStaleUnixSocket(address)
}
@@ -41,17 +75,26 @@ func listenOnAddress(addr string) (*socketListener, error) {
func parseListenAddress(addr string) (string, string, error) {
network, address, ok := strings.Cut(addr, "://")
if !ok || network == "" || address == "" {
return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr)
return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr)
}
switch network {
case "unix", "tcp":
case "unix", "tcp", "npipe":
return network, address, nil
default:
return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)
}
}
// pipePath maps a daemon-addr npipe name ("npipe://netbird") to a Windows
// named-pipe path (\\.\pipe\netbird).
func pipePath(name string) string {
if strings.HasPrefix(name, `\\`) {
return name
}
return `\\.\pipe\` + name
}
func removeStaleUnixSocket(path string) {
stat, err := os.Lstat(path)
if err != nil {

View File

@@ -0,0 +1,63 @@
//go:build !ios && !android
package cmd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMigrateLegacyDaemonAddrForOS(t *testing.T) {
cases := []struct {
name string
goos string
addr string
want string
migrate bool
}{
{
name: "windows legacy tcp migrates to pipe",
goos: "windows",
addr: legacyWindowsDaemonAddr,
want: windowsPipeDaemonAddr,
migrate: true,
},
{
name: "windows pipe already migrated stays",
goos: "windows",
addr: windowsPipeDaemonAddr,
want: windowsPipeDaemonAddr,
migrate: false,
},
{
name: "windows custom tcp left alone",
goos: "windows",
addr: "tcp://127.0.0.1:9999",
want: "tcp://127.0.0.1:9999",
migrate: false,
},
{
name: "linux legacy-looking tcp not migrated",
goos: "linux",
addr: legacyWindowsDaemonAddr,
want: legacyWindowsDaemonAddr,
migrate: false,
},
{
name: "linux unix socket untouched",
goos: "linux",
addr: "unix:///var/run/netbird.sock",
want: "unix:///var/run/netbird.sock",
migrate: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := migrateLegacyDaemonAddrForOS(tc.goos, tc.addr)
assert.Equal(t, tc.want, got)
assert.Equal(t, tc.migrate, ok)
})
}
}

View File

@@ -0,0 +1,73 @@
package cmd
import (
"fmt"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
var (
setSSHConfigProfile string
setSSHConfigUsername string
setSSHConfigEnableRoot bool
setSSHConfigDisableAuth bool
)
// setSSHConfigCmd applies the privileged SSH settings (enable root login /
// disable auth) to a profile over the daemon IPC. Hidden because users
// interact via `netbird up` / the UI, not directly.
var setSSHConfigCmd = &cobra.Command{
Use: SetSSHConfigCmdName,
Short: "Apply privileged SSH settings to a profile (internal elevation target)",
Hidden: true,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := internal.CtxInitState(cmd.Context())
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
return fmt.Errorf("connect to daemon: %w", err)
}
defer func() {
if cerr := conn.Close(); cerr != nil {
log.Warnf("failed closing daemon gRPC client connection: %v", cerr)
}
}()
var enableRoot, disableAuth *bool
if cmd.Flags().Changed(enableSSHRootFlag) {
enableRoot = &setSSHConfigEnableRoot
}
if cmd.Flags().Changed(disableSSHAuthFlag) {
disableAuth = &setSSHConfigDisableAuth
}
req := buildSetSSHConfigRequest(setSSHConfigProfile, setSSHConfigUsername, enableRoot, disableAuth)
if _, err := proto.NewDaemonServiceClient(conn).SetConfig(ctx, req); err != nil {
return fmt.Errorf("apply SSH config: %w", err)
}
return nil
},
}
// buildSetSSHConfigRequest builds a minimal SetConfigRequest that touches only
// the SSH fields that were provided (nil pointers leave the daemon's stored
// value untouched, matching setupSetConfigReq's pointer semantics).
func buildSetSSHConfigRequest(profileName, username string, enableSSHRoot, disableSSHAuth *bool) *proto.SetConfigRequest {
return &proto.SetConfigRequest{
ProfileName: profileName,
Username: username,
EnableSSHRoot: enableSSHRoot,
DisableSSHAuth: disableSSHAuth,
}
}
func init() {
setSSHConfigCmd.Flags().StringVar(&setSSHConfigProfile, "profile", "", "profile name to apply the SSH settings to")
setSSHConfigCmd.Flags().StringVar(&setSSHConfigUsername, "username", "", "owning username of the profile")
setSSHConfigCmd.Flags().BoolVar(&setSSHConfigEnableRoot, enableSSHRootFlag, false, "enable root login for the SSH server")
setSSHConfigCmd.Flags().BoolVar(&setSSHConfigDisableAuth, disableSSHAuthFlag, false, "disable SSH authentication")
}

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"net"
"net/netip"
"os/user"
"runtime"
"strings"
"time"
@@ -172,10 +174,9 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (
return resp, nil
}
// getActiveProfileName asks the daemon for the active profile's display
// name. The daemon runs as root and can read the per-user profile files to
// resolve the ID to its human-readable name. Returns an empty string on any
// error so status output degrades gracefully.
// getActiveProfileName asks the daemon for the active profile's display name,
// annotated with the owning user when the active profile belongs to someone else.
// Returns an empty string on any error so status output degrades gracefully.
func getActiveProfileName(ctx context.Context) string {
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
@@ -188,7 +189,22 @@ func getActiveProfileName(ctx context.Context) string {
return ""
}
return resp.GetProfileName()
name := resp.GetProfileName()
if owner := resp.GetUsername(); owner != "" {
if curr, uerr := user.Current(); uerr != nil || !usernamesMatch(owner, curr.Username) {
name = fmt.Sprintf("%s (user %s)", name, owner)
}
}
return name
}
// usernamesMatch compares usernames case-insensitively on Windows (domain
// accounts) and exactly elsewhere.
func usernamesMatch(a, b string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(a, b)
}
return a == b
}
func parseFilters() error {

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"
@@ -56,6 +56,7 @@ var (
showQR bool
profileName string
configPath string
claimOwner bool
upCmd = &cobra.Command{
Use: "up",
@@ -67,6 +68,7 @@ var (
func init() {
upCmd.PersistentFlags().BoolVarP(&foregroundMode, "foreground-mode", "F", false, "start service in foreground")
upCmd.PersistentFlags().BoolVar(&claimOwner, "owner", false, "claim ownership of this profile for the current user, restricting daemon control of it to you and root/administrator")
upCmd.PersistentFlags().StringVar(&interfaceName, interfaceNameFlag, iface.WgInterfaceDefault, "WireGuard interface name")
upCmd.PersistentFlags().Uint16Var(&wireguardPort, wireguardPortFlag, iface.DefaultWgPort, "WireGuard interface listening port")
upCmd.PersistentFlags().Uint16Var(&mtu, mtuFlag, iface.DefaultMTU, "Set MTU (Maximum Transmission Unit) for the WireGuard interface")
@@ -319,6 +321,24 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
return fmt.Errorf("get current user: %v", err)
}
// Enabling SSH root login / disabling SSH auth is a privileged change the
// daemon rejects from a non-privileged caller. Offer to elevate (polkit/UAC/
// osascript) so an elevated helper applies just those settings.
if wantsDangerousSSH(cmd) && !isProcessPrivileged() {
cmd.Println("Enabling SSH root login / disabling SSH authentication requires administrator privileges, requesting elevation...")
var enableRoot, disableAuth *bool
if cmd.Flag(enableSSHRootFlag).Changed {
enableRoot = &enableSSHRoot
}
if cmd.Flag(disableSSHAuthFlag).Changed {
disableAuth = &disableSSHAuth
}
if err := ElevateSSHConfig(activeProf.ID.String(), username.Username, enableRoot, disableAuth); err != nil {
return fmt.Errorf("apply privileged SSH settings: %w", err)
}
sshConfigElevated = true
}
// set the new config
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
if _, err := client.SetConfig(ctx, req); err != nil {
@@ -391,6 +411,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
if _, err := client.Up(ctx, &proto.UpRequest{
ProfileName: &profileID,
Username: &username,
ClaimOwner: claimOwner,
}); err != nil {
return fmt.Errorf("call service up method: %v", err)
}
@@ -421,7 +442,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
if cmd.Flag(serverSSHAllowedFlag).Changed {
req.ServerSSHAllowed = &serverSSHAllowed
}
if cmd.Flag(enableSSHRootFlag).Changed {
if cmd.Flag(enableSSHRootFlag).Changed && !sshConfigElevated {
req.EnableSSHRoot = &enableSSHRoot
}
if cmd.Flag(enableSSHSFTPFlag).Changed {
@@ -433,7 +454,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
}
if cmd.Flag(disableSSHAuthFlag).Changed {
if cmd.Flag(disableSSHAuthFlag).Changed && !sshConfigElevated {
req.DisableSSHAuth = &disableSSHAuth
}
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
@@ -649,7 +670,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
loginRequest.ServerSSHAllowed = &serverSSHAllowed
}
if cmd.Flag(enableSSHRootFlag).Changed {
if cmd.Flag(enableSSHRootFlag).Changed && !sshConfigElevated {
loginRequest.EnableSSHRoot = &enableSSHRoot
}
@@ -665,7 +686,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
}
if cmd.Flag(disableSSHAuthFlag).Changed {
if cmd.Flag(disableSSHAuthFlag).Changed && !sshConfigElevated {
loginRequest.DisableSSHAuth = &disableSSHAuth
}

View File

@@ -29,7 +29,7 @@ func TestUpDaemon(t *testing.T) {
}
sm := profilemanager.ServiceManager{}
created, err := sm.AddProfile("test1", currUser.Username)
created, err := sm.AddProfile("test1", currUser.Username, nil)
if err != nil {
t.Fatalf("failed to add profile: %v", err)
return

View File

@@ -24,11 +24,7 @@ import (
)
const (
// Skew tolerates a small clock difference between the management
// server and this peer before treating a deadline as "in the past".
// Slightly above typical NTP drift; tight enough that the UI doesn't
// paint a stale expiry as if it were valid.
Skew = 30 * time.Second
maxPastHorizon = 30 * 24 * time.Hour
// maxDeadlineHorizon caps how far in the future an accepted deadline
// can sit. A timestamp beyond this is almost certainly a protocol
@@ -57,7 +53,7 @@ var (
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
// ErrDeadlineInPast is returned by Update when the supplied deadline
// is more than Skew in the past.
// is more than maxPastHorizon in the past.
ErrDeadlineInPast = errors.New("session deadline in the past")
)
@@ -66,15 +62,14 @@ var (
// for deadline change/clear, PublishEvent for the two warnings); tests pass
// a fake recorder so the same surface is observable without an engine.
//
// The watcher is the single owner of the deadline propagated to the
// recorder: every set, clear, sanity-check rejection and Close routes the
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
// fans out its own state-change notification, so no separate notify is
// needed.) The recorder is server-scoped and outlives this engine-scoped
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
// a profile switch) would leave the next session showing the previous one's
// stale "expires in" value.
// While the watcher runs, it owns the deadline propagated to the recorder:
// every set, clear and sanity-check rejection routes the value through
// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can
// never drift from the watcher's timer state. (SetSessionExpiresAt fans
// out its own state-change notification, so no separate notify is needed.)
// The recorder is server-scoped and outlives this engine-scoped watcher;
// Close deliberately leaves the recorder value in place so transient engine
// restarts don't blank it — the client run loop clears it on real teardown.
//
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
// composes the metadata internally so the wire format (MetaSession*) is
@@ -135,10 +130,13 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
// was disabled).
//
// Same-value updates are no-ops. A different non-zero value cancels any
// pending timer, resets the "already fired" guard, and arms a new one.
// pending timer, resets the "already fired" guards, and — when the
// deadline lies in the future — arms fresh warning timers. A deadline
// already in the past (within maxPastHorizon) is recorded as-is with no
// timers: the session has expired and consumers render it that way.
//
// Returns one of the sentinel Err* values when the deadline fails the
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
// In every error case the watcher first clears its state so it stays
// consistent with what the caller will push into its other sinks (e.g.
// applySessionDeadline forces a zero deadline into the status recorder
@@ -163,7 +161,7 @@ func (w *Watcher) Update(deadline time.Time) error {
case deadline.After(now.Add(maxDeadlineHorizon)):
w.clearLocked()
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
case deadline.Before(now.Add(-Skew)):
case deadline.Before(now.Add(-maxPastHorizon)):
w.clearLocked()
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
}
@@ -183,7 +181,9 @@ func (w *Watcher) Update(deadline time.Time) error {
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
w.armTimerLocked(deadline)
if deadline.After(now) {
w.armTimerLocked(deadline)
}
recorder := w.recorder
w.mu.Unlock()
if recorder != nil {
@@ -227,30 +227,25 @@ func (w *Watcher) Dismiss() {
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
}
// Close stops any pending timer and drops the deadline on the status
// recorder. Update calls after Close are ignored. Clearing the recorder
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
// from leaving the next session showing this one's stale "expires in"
// value — the recorder is server-scoped and outlives this engine-scoped
// watcher, so nothing else drops the anchor on teardown.
// Close stops any pending timer. Update calls after Close are ignored.
// The recorder keeps its deadline: the watcher is engine-scoped and closes
// on every engine restart (network change, sleep/wake, stream errors)
// while the SSO deadline stays valid across those, so clearing here would
// blank the UI's "expires in" row on every transient reconnect. The
// client run loop clears the server-scoped recorder when it exits for
// real (Down, profile switch, permanent login failure).
func (w *Watcher) Close() {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
w.mu.Unlock()
return
}
w.closed = true
w.stopTimerLocked()
hadDeadline := !w.current.IsZero()
w.current = time.Time{}
w.firedAt = time.Time{}
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
recorder := w.recorder
w.mu.Unlock()
if recorder != nil && hadDeadline {
recorder.SetSessionExpiresAt(time.Time{})
}
}
// clearLocked drops the tracked deadline and notifies the recorder so

View File

@@ -224,11 +224,13 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
r := &fakeRecorder{}
lead := 30 * time.Millisecond
lead := 150 * time.Millisecond
w := newWatcher(lead, r)
defer w.Close()
first := time.Now().Add(50 * time.Millisecond)
// Warning fires ~20ms in; the deadline itself stays 150ms away so the
// replacement below lands well before it.
first := time.Now().Add(170 * time.Millisecond)
_ = w.Update(first)
// Wait for stateChange + warning of the first cycle.
@@ -306,7 +308,29 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) {
}
}
func TestUpdateInPastClearsDeadline(t *testing.T) {
func TestUpdateRecentPastRecordedAsExpired(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
d := time.Now().Add(-1 * time.Hour)
if err := w.Update(d); err != nil {
t.Fatalf("recent-past Update should succeed, got %v", err)
}
if !w.Deadline().Equal(d) {
t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d)
}
if got := r.deadline(); !got.Equal(d) {
t.Fatalf("recorder deadline = %v, want %v", got, d)
}
time.Sleep(80 * time.Millisecond)
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot())
}
}
func TestUpdateAncientPastRejected(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
@@ -318,12 +342,12 @@ func TestUpdateInPastClearsDeadline(t *testing.T) {
// Drain the stateChange from the seed.
waitForEvents(t, r, 1)
err := w.Update(time.Now().Add(-1 * time.Hour))
err := w.Update(time.Now().Add(-31 * 24 * time.Hour))
if !errors.Is(err, ErrDeadlineInPast) {
t.Fatalf("want ErrDeadlineInPast, got %v", err)
}
if !w.Deadline().IsZero() {
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline())
}
events := waitForEvents(t, r, 2)
if events[1].kind != stateChange {
@@ -331,39 +355,25 @@ func TestUpdateInPastClearsDeadline(t *testing.T) {
}
}
func TestUpdateWithinSkewAccepted(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
d := time.Now().Add(-5 * time.Second)
if err := w.Update(d); err != nil {
t.Fatalf("within-skew Update should succeed, got %v", err)
}
if !w.Deadline().Equal(d) {
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
}
}
func TestCloseSilencesUpdates(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
w.Close()
_ = w.Update(time.Now().Add(time.Hour))
time.Sleep(20 * time.Millisecond)
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
t.Fatalf("Update after Close: want nil, got %v", err)
}
if got := r.snapshot(); len(got) != 0 {
t.Fatalf("expected no events after Close, got %+v", got)
}
}
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
// holding a live deadline must zero the recorder on Close so the next
// engine's watcher (and the UI reading the shared server-scoped recorder)
// doesn't start out showing the previous session's stale "expires in".
func TestCloseClearsRecorderDeadline(t *testing.T) {
// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher
// closes on every engine restart (network change, sleep/wake) while the
// SSO deadline stays valid across those, so Close must leave the
// server-scoped recorder's value in place. The client run loop clears the
// recorder when it exits for real.
func TestCloseKeepsRecorderDeadline(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(time.Hour, r)
@@ -377,8 +387,8 @@ func TestCloseClearsRecorderDeadline(t *testing.T) {
w.Close()
if got := r.deadline(); !got.IsZero() {
t.Fatalf("recorder deadline after Close = %v, want zero", got)
if got := r.deadline(); !got.Equal(d) {
t.Fatalf("recorder deadline after Close = %v, want %v", got, d)
}
}

View File

@@ -257,7 +257,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
log.Errorf("failed to clean up temporary installer file: %v", err)
}
defer c.statusRecorder.ClientStop()
defer func() {
c.statusRecorder.SetSessionExpiresAt(time.Time{})
c.statusRecorder.ClientStop()
}()
operation := func() error {
// if context cancelled we not start new backoff cycle
if c.ctx.Err() != nil {

View File

@@ -75,4 +75,14 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) {
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
"invalid timestamp must clear the deadline")
})
t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
e := newEngine()
expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
e.ApplySessionDeadline(timestamppb.New(expired))
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
"recently-expired deadline must stay on the recorder so consumers render it as expired")
})
}

View File

@@ -0,0 +1,84 @@
package ipcauth
import (
"slices"
"strconv"
)
// Ownership is a profile's access policy: the typed owner principals plus the
// opt-in shared flag.
type Ownership struct {
Owners []string
Shared bool
}
// GroupResolver resolves a Unix caller's effective group IDs and owner group
// names to GIDs. A nil resolver disables group matching.
type GroupResolver interface {
// CallerGIDs returns the set of group IDs the caller belongs to.
CallerGIDs(id Identity) map[uint32]struct{}
// GroupNameGID resolves a group name to its GID.
GroupNameGID(name string) (uint32, bool)
}
// Authorize reports whether the identity may control a profile with the given
// ownership. Privileged callers and shared profiles are always allowed.
func Authorize(o Ownership, id Identity, r GroupResolver) bool {
if id.IsPrivileged() {
return true
}
if o.Shared {
return true
}
for _, raw := range o.Owners {
p, ok := ParsePrincipal(raw)
if !ok {
continue
}
if principalMatches(p, id, r) {
return true
}
}
return false
}
func principalMatches(p Principal, id Identity, r GroupResolver) bool {
switch p.Kind {
case KindUID:
if id.IsWindows() {
return false
}
uid, err := strconv.ParseUint(p.Value, 10, 32)
return err == nil && uint32(uid) == id.UID
case KindGID:
if id.IsWindows() {
return false
}
gid, err := strconv.ParseUint(p.Value, 10, 32)
return err == nil && callerHasGID(uint32(gid), id, r)
case KindGroup:
if id.IsWindows() || r == nil {
return false
}
gid, ok := r.GroupNameGID(p.Value)
return ok && callerHasGID(gid, id, r)
case KindSID:
if !id.IsWindows() {
return false
}
return id.SID == p.Value || slices.Contains(id.Groups, p.Value)
default:
return false
}
}
func callerHasGID(gid uint32, id Identity, r GroupResolver) bool {
if id.GID == gid {
return true
}
if r == nil {
return false
}
_, ok := r.CallerGIDs(id)[gid]
return ok
}

View File

@@ -0,0 +1,22 @@
//go:build !linux && !darwin && !freebsd && !windows
package ipcauth
import (
"fmt"
"net"
"runtime"
"google.golang.org/grpc/credentials"
)
// NewTransportCredentials returns nil on platforms without a peer-identity
// primitive.
func NewTransportCredentials() credentials.TransportCredentials {
return nil
}
// ConnIdentity is unsupported on platforms without a peer-identity primitive.
func ConnIdentity(net.Conn) (Identity, error) {
return Identity{}, fmt.Errorf("peer identity not supported on %s", runtime.GOOS)
}

View File

@@ -0,0 +1,51 @@
//go:build linux || darwin || freebsd
package ipcauth
import (
"context"
"net"
"google.golang.org/grpc/credentials"
)
// NewTransportCredentials returns gRPC transport credentials that extract the
// caller's kernel-authenticated identity from a Unix-socket connection and
// expose it via IdentityFromContext. Non-nil on platforms with a
// peer-credential primitive.
func NewTransportCredentials() credentials.TransportCredentials {
return unixCreds{}
}
type unixCreds struct{}
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return conn, AuthInfo{}, nil
}
// ConnIdentity extracts the caller's identity from an accepted local IPC
// connection. On Unix it reads peer credentials from the socket. It is shared
// by the gRPC transport credentials and the JSON gateway (which forwards it).
func ConnIdentity(conn net.Conn) (Identity, error) {
return PeerIdentity(conn)
}
// ServerHandshake extracts the peer identity and fails closed if it cannot be read.
func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
id, err := ConnIdentity(conn)
if err != nil {
return nil, nil, err
}
return conn, AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: id,
}, nil
}
func (unixCreds) Info() credentials.ProtocolInfo {
return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
}
func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} }
func (unixCreds) OverrideServerName(string) error { return nil }

View File

@@ -0,0 +1,135 @@
//go:build windows
package ipcauth
import (
"context"
"fmt"
"net"
"runtime"
"golang.org/x/sys/windows"
"google.golang.org/grpc/credentials"
)
var (
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
)
// DefaultPipeSDDL keeps the daemon control pipe open to any LOCAL caller,
// like Unix socket with 0666 permissions.
//
// D:P protected DACL, no inheritance
// (D;;GA;;;NU) deny GENERIC_ALL to NETWORK (remote/SMB)
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon itself)
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone (local, per-RPC ACL gates)
func DefaultPipeSDDL() string {
return "D:P(D;;GA;;;NU)(A;;GA;;;SY)(A;;GA;;;WD)"
}
// NewTransportCredentials returns gRPC transport credentials that derive the
// caller's identity from the named-pipe client token.
//
// This requires the client to dial at SECURITY_IDENTIFICATION (see dialNamedPipe).
func NewTransportCredentials() credentials.TransportCredentials {
return winpipeCreds{}
}
type winpipeCreds struct{}
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return conn, AuthInfo{}, nil
}
// ConnIdentity extracts the caller's identity from an accepted named-pipe
// connection by impersonating the pipe client and reading its token. It is
// shared by the gRPC transport credentials and the JSON gateway (which forwards
// it). Requires the client to have connected at SECURITY_IDENTIFICATION.
func ConnIdentity(conn net.Conn) (Identity, error) {
// go-winio's pipe connection embeds *win32File, which exposes Fd().
fdConn, ok := conn.(interface{ Fd() uintptr })
if !ok {
return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn)
}
return pipeClientIdentity(windows.Handle(fdConn.Fd()))
}
// ServerHandshake extracts the connecting client's identity from the pipe. Fails
// closed if the handle or token cannot be read.
func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
id, err := ConnIdentity(conn)
if err != nil {
return nil, nil, err
}
return conn, AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: id,
}, nil
}
func (winpipeCreds) Info() credentials.ProtocolInfo {
return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
}
func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} }
func (winpipeCreds) OverrideServerName(string) error { return nil }
// pipeClientIdentity reads the connecting client's user SID, enabled group SIDs,
// and elevation by impersonating the pipe client on this thread and reading the
// impersonation token.
func pipeClientIdentity(handle windows.Handle) (id Identity, err error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err = impersonateNamedPipeClient(handle); err != nil {
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
}
defer func() {
// Surface revert error if there are no other errors.
revErr := windows.RevertToSelf()
if err == nil {
err = revErr
}
}()
// openAsSelf=true: the token is opened using the daemon's process context
// (LocalSystem), not the impersonated client's, so the open always succeeds.
var token windows.Token
if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil {
return Identity{}, fmt.Errorf("open thread token: %w", err)
}
defer token.Close()
tu, err := token.GetTokenUser()
if err != nil {
return Identity{}, fmt.Errorf("get token user: %w", err)
}
tg, err := token.GetTokenGroups()
if err != nil {
return Identity{}, fmt.Errorf("get token groups: %w", err)
}
var groups []string
for _, g := range tg.AllGroups() {
if g.Attributes&windows.SE_GROUP_ENABLED == 0 || g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
continue
}
groups = append(groups, g.Sid.String())
}
return Identity{
SID: tu.User.Sid.String(),
Groups: groups,
Elevated: token.IsElevated(),
}, nil
}
func impersonateNamedPipeClient(h windows.Handle) error {
r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
if r == 0 {
return e
}
return nil
}

View File

@@ -0,0 +1,77 @@
package ipcauth
import (
"context"
"strconv"
"google.golang.org/grpc/metadata"
)
// Metadata keys used by the local JSON gateway to forward the HTTP client's
// identity to the daemon.
const (
mdFwdUID = "x-netbird-fwd-uid" // Unix
mdFwdGID = "x-netbird-fwd-gid" // Unix
mdFwdSID = "x-netbird-fwd-sid" // Windows user SID
mdFwdGroup = "x-netbird-fwd-group" // Windows group SID (repeated)
mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" if elevated
)
// ForwardIdentityMetadata encodes an identity for the gateway to forward to the
// daemon.
func ForwardIdentityMetadata(id Identity) metadata.MD {
if id.IsWindows() {
md := metadata.MD{}
md.Set(mdFwdSID, id.SID)
if len(id.Groups) > 0 {
md.Set(mdFwdGroup, id.Groups...)
}
if id.Elevated {
md.Set(mdFwdElevated, "1")
}
return md
}
return metadata.Pairs(
mdFwdUID, strconv.FormatUint(uint64(id.UID), 10),
mdFwdGID, strconv.FormatUint(uint64(id.GID), 10),
)
}
// forwardedIdentity extracts a forwarded identity from incoming gRPC metadata
func forwardedIdentity(ctx context.Context) (Identity, bool) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return Identity{}, false
}
if sid := mdFirst(md, mdFwdSID); sid != "" {
return Identity{
SID: sid,
Groups: md.Get(mdFwdGroup),
Elevated: mdFirst(md, mdFwdElevated) == "1",
}, true
}
uidStr := mdFirst(md, mdFwdUID)
if uidStr == "" {
return Identity{}, false
}
uid, err := strconv.ParseUint(uidStr, 10, 32)
if err != nil {
return Identity{}, false
}
id := Identity{UID: uint32(uid)}
if g := mdFirst(md, mdFwdGID); g != "" {
if v, err := strconv.ParseUint(g, 10, 32); err == nil {
id.GID = uint32(v)
}
}
return id, true
}
func mdFirst(md metadata.MD, key string) string {
if v := md.Get(key); len(v) > 0 {
return v[0]
}
return ""
}

View File

@@ -0,0 +1,41 @@
package ipcauth
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/metadata"
)
func TestForwardIdentityRoundTrip(t *testing.T) {
cases := []struct {
name string
id Identity
}{
{"unix uid/gid", Identity{UID: 1000, GID: 1000}},
{"windows sid+groups+elevated", Identity{
SID: "S-1-5-21-1-2-3-1001",
Groups: []string{"S-1-5-32-544", "S-1-1-0"},
Elevated: true,
}},
{"windows sid only", Identity{SID: "S-1-5-21-9"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := metadata.NewIncomingContext(context.Background(), ForwardIdentityMetadata(tc.id))
got, ok := forwardedIdentity(ctx)
assert.True(t, ok)
assert.Equal(t, tc.id, got)
})
}
}
func TestForwardedIdentity_None(t *testing.T) {
_, ok := forwardedIdentity(context.Background())
assert.False(t, ok, "no metadata, no forwarded identity")
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("other", "x"))
_, ok = forwardedIdentity(ctx)
assert.False(t, ok)
}

View File

@@ -0,0 +1,89 @@
// Package ipcauth provides the kernel-authenticated identity of a local IPC
// (gRPC) caller and the transport credentials that surface it into the gRPC
// context, so the daemon can authorize each RPC by caller identity.
//
// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the named-pipe
// client token. Platforms without a peer-identity primitive get no credentials
// and therefore no enforcement.
package ipcauth
import (
"context"
"fmt"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
)
// sidLocalSystem is the well-known Windows SID for the LocalSystem account.
const sidLocalSystem = "S-1-5-18"
// Identity is the kernel-authenticated identity of a local IPC caller. The zero
// value is not a valid identity.
type Identity struct {
// UID and GID are the caller's Unix user ID and primary group ID.
// Zero on Windows, where SID is authoritative instead.
UID uint32
GID uint32
// SID is the caller's Windows security identifier (empty on Unix).
SID string
// Groups holds the caller's Windows group SIDs, captured from the client
// token at handshake (empty on Unix, where supplementary group membership is
// resolved on demand via NSS/getent by the authorizer).
Groups []string
// Elevated reports whether the Windows client token is elevated (run as
// administrator). Always false on Unix, where privilege is uid==0.
Elevated bool
}
// IsWindows reports whether this identity is a Windows principal (SID-based)
// rather than a Unix uid/gid principal.
func (i Identity) IsWindows() bool {
return i.SID != ""
}
// IsPrivileged reports whether the caller is the platform's administrative
// principal.
func (i Identity) IsPrivileged() bool {
if i.IsWindows() {
return i.Elevated || i.SID == sidLocalSystem
}
return i.UID == 0
}
// String renders the identity for audit logs.
func (i Identity) String() string {
if i.IsWindows() {
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
}
return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
}
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so the
// interceptor can retrieve it from the request context via IdentityFromContext.
type AuthInfo struct {
credentials.CommonAuthInfo
Identity Identity
}
// AuthType identifies the authentication scheme.
func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
// IdentityFromContext extracts the caller's kernel-authenticated identity from
// the gRPC peer context. The second return value is false when no IPC transport
// credentials were negotiated, callers MUST fail closed in that case.
func IdentityFromContext(ctx context.Context) (Identity, bool) {
p, ok := peer.FromContext(ctx)
if !ok {
return Identity{}, false
}
info, ok := p.AuthInfo.(AuthInfo)
if !ok {
return Identity{}, false
}
return info.Identity, true
}

View File

@@ -0,0 +1,50 @@
package ipcauth
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
)
func TestIdentityFromContext_NoPeer(t *testing.T) {
_, ok := IdentityFromContext(context.Background())
assert.False(t, ok, "bare context must report no identity (fail closed)")
}
func TestIdentityFromContext_WrongAuthInfo(t *testing.T) {
ctx := peer.NewContext(context.Background(), &peer.Peer{})
_, ok := IdentityFromContext(ctx)
assert.False(t, ok, "peer without our AuthInfo must report no identity")
}
func TestIdentityFromContext_Present(t *testing.T) {
want := Identity{UID: 1000, GID: 1000}
ctx := peer.NewContext(context.Background(), &peer.Peer{
AuthInfo: AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: want,
},
})
got, ok := IdentityFromContext(ctx)
assert.True(t, ok)
assert.Equal(t, want, got)
}
func TestIdentity_IsPrivileged(t *testing.T) {
// Unix
assert.True(t, Identity{UID: 0}.IsPrivileged(), "root is privileged")
assert.False(t, Identity{UID: 1000}.IsPrivileged(), "non-root is not privileged")
// Windows
assert.True(t, Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}.IsPrivileged(), "elevated admin is privileged")
assert.True(t, Identity{SID: "S-1-5-18"}.IsPrivileged(), "LocalSystem is privileged")
assert.False(t, Identity{SID: "S-1-5-21-1-2-3-1001"}.IsPrivileged(), "non-elevated admin is NOT privileged")
}
func TestIdentity_IsWindows(t *testing.T) {
assert.True(t, Identity{SID: "S-1-5-18"}.IsWindows())
assert.False(t, Identity{UID: 0}.IsWindows())
}

View File

@@ -0,0 +1,152 @@
package ipcauth
import (
"context"
"os"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Interceptor enforces per-RPC authorization on the daemon IPC, keyed to
// the caller's kernel-authenticated identity. It is safe-by-default:
// any RPC without a matching bypass is gated by the active profile's ownership,
// and a caller without a readable identity is denied.
type Interceptor struct {
policy ProfilePolicy
resolver GroupResolver
// selfUID is the daemon's own effective UID. -1 on Windows.
selfUID int
}
// NewInterceptor builds an interceptor over the given policy and group resolver.
func NewInterceptor(policy ProfilePolicy, resolver GroupResolver) *Interceptor {
return &Interceptor{policy: policy, resolver: resolver, selfUID: os.Geteuid()}
}
// UnaryServerInterceptor authorizes each unary RPC before the handler runs.
func (i *Interceptor) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if err := i.authorize(ctx, info.FullMethod); err != nil {
return nil, err
}
return handler(ctx, req)
}
}
// StreamServerInterceptor authorizes each streaming RPC before the handler runs.
func (i *Interceptor) StreamServerInterceptor() grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if err := i.authorize(ss.Context(), info.FullMethod); err != nil {
return err
}
return handler(srv, ss)
}
}
// authorize decides each RPC from the caller's identity, first match wins:
//
// 0. no identity DENY
// 1. self / privileged / forwarded ALLOW (root, elevated, daemon-self, JSON gateway)
// 2. in ownersAuthorizedMethods owner tier (gate on DaemonOwnership)
// 3. in handlerAuthorizedMethods profile tier (bypass, handler decides)
// 4. everything else default tier (gate on ActiveProfileOwnership)
func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error {
id, ok := IdentityFromContext(ctx)
if !ok {
log.Warnf("ipc authz: DENY %s. caller identity unavailable", fullMethod)
return status.Error(codes.PermissionDenied, "caller identity could not be verified on the daemon control channel")
}
if i.isSelfOrPrivileged(id) {
// The local JSON gateway connects as the daemon itself (self/privileged)
// and forwards the real HTTP client's identity. Trust it here, where
// the transport peer is already the daemon, then authorize as the
// forwarded client. A direct non-privileged caller never reaches this
// branch, so it cannot forge the forwarding metadata.
fwd, hasFwd := forwardedIdentity(ctx)
if !hasFwd {
i.auditAllow(id, fullMethod)
return nil
}
log.Infof("ipc authz: gateway-forwarded identity %s", fwd)
id = fwd
if i.isSelfOrPrivileged(id) {
i.auditAllow(id, fullMethod)
return nil
}
}
// Owner tier: daemon-level RPCs (Add, Down, Status) require a daemon-wide owner.
if ownersAuthorizedMethods[fullMethod] {
allowed, err := i.authorizeOwnership(id, i.policy.DaemonOwnership, i.policy.ClaimDaemonOwnerIfUnowned)
if err != nil {
log.Errorf("ipc authz: claim daemon owner for %s: %v", id, err)
return status.Error(codes.Internal, "failed to claim daemon ownership")
}
if allowed {
i.auditAllow(id, fullMethod)
return nil
}
log.Warnf("ipc authz: DENY %s for %s. not a daemon owner", fullMethod, id)
return status.Errorf(codes.PermissionDenied,
"not authorized (caller %s is not a daemon owner). ask an owner or run as root/administrator", id)
}
// Profile tier: the handler self-authorizes against the target profile.
if handlerAuthorizedMethods[fullMethod] {
i.auditAllow(id, fullMethod)
return nil
}
// Default: gated on the active profile's ownership.
allowed, err := i.authorizeOwnership(id, i.policy.ActiveProfileOwnership, i.policy.ClaimActiveProfileOwnerIfUnowned)
if err != nil {
log.Errorf("ipc authz: claim active profile for %s: %v", id, err)
return status.Error(codes.Internal, "failed to claim profile ownership")
}
if allowed {
i.auditAllow(id, fullMethod)
return nil
}
log.Warnf("ipc authz: DENY %s for %s. active profile owned by another principal", fullMethod, id)
return status.Errorf(codes.PermissionDenied,
"not authorized to control the active profile (caller %s). ask an owner or run as root/administrator", id)
}
// authorizeOwnership authorizes id against an ownership set, claiming it via
// trust-on-first-use when it is unowned and unshared. The claim is atomic, so on
// a lost race it re-reads and authorizes normally.
func (i *Interceptor) authorizeOwnership(id Identity, get func() Ownership, claim func(Identity) (bool, error)) (bool, error) {
o := get()
if len(o.Owners) == 0 && !o.Shared {
claimed, err := claim(id)
if err != nil {
return false, err
}
if claimed {
return true, nil
}
o = get()
}
return Authorize(o, id, i.resolver), nil
}
// isSelfOrPrivileged reports whether the caller is the platform administrator
// (root / elevated-admin / LocalSystem) or the daemon's own user.
func (i *Interceptor) isSelfOrPrivileged(id Identity) bool {
if id.IsPrivileged() {
return true
}
// Daemon-self: only meaningful on Unix (Windows privilege is covered above).
return !id.IsWindows() && i.selfUID >= 0 && int(id.UID) == i.selfUID
}
func (i *Interceptor) auditAllow(id Identity, fullMethod string) {
if auditMethods[fullMethod] {
log.Infof("ipc authz: allow %s for %s", fullMethod, id)
}
}

View File

@@ -0,0 +1,176 @@
package ipcauth
import (
"context"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
type mockPolicy struct {
o Ownership // active profile ownership
daemon Ownership // daemon-wide ownership
claimed bool
daemonClaimed bool
}
func (m *mockPolicy) ActiveProfileOwnership() Ownership { return m.o }
// ClaimActiveProfileOwnerIfUnowned records a claim and marks the profile owned.
func (m *mockPolicy) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
if len(m.o.Owners) == 0 && !m.o.Shared {
m.o.Owners = []string{OwnerPrincipalForIdentity(id)}
m.claimed = true
return true, nil
}
return false, nil
}
func (m *mockPolicy) DaemonOwnership() Ownership { return m.daemon }
// ClaimDaemonOwnerIfUnowned records a daemon claim and marks the daemon owned.
func (m *mockPolicy) ClaimDaemonOwnerIfUnowned(id Identity) (bool, error) {
if len(m.daemon.Owners) == 0 && !m.daemon.Shared {
m.daemon.Owners = []string{OwnerPrincipalForIdentity(id)}
m.daemonClaimed = true
return true, nil
}
return false, nil
}
type mockResolver struct {
gids map[uint32]struct{}
names map[string]uint32
}
func (m mockResolver) CallerGIDs(Identity) map[uint32]struct{} { return m.gids }
func (m mockResolver) GroupNameGID(n string) (uint32, bool) { g, ok := m.names[n]; return g, ok }
func ctxWith(id Identity) context.Context {
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: AuthInfo{Identity: id}})
}
const (
up = servicePath + "Up"
list = servicePath + "ListProfiles"
unkwn = servicePath + "SomeFutureMethod"
down = servicePath + "Down"
statusm = servicePath + "Status"
addp = servicePath + "AddProfile"
switchp = servicePath + "SwitchProfile"
addowner = servicePath + "AddOwner"
sharep = servicePath + "ShareProfile"
)
func TestInterceptorAuthorize(t *testing.T) {
const selfUID = 4000
tests := []struct {
name string
own Ownership // active profile ownership
daemon Ownership // daemon-wide ownership
resolver GroupResolver
ctx context.Context
method string
wantErr bool
}{
// Default gate (active profile ownership).
{"no identity denies", Ownership{}, Ownership{}, nil, context.Background(), up, true},
{"root allowed", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: 0}), up, false},
{"daemon-self allowed", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: selfUID}), up, false},
{"shared allows any", Ownership{Shared: true}, Ownership{}, nil, ctxWith(Identity{UID: 1234}), up, false},
{"uid owner allowed", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 1000}), up, false},
{"non-owner denied", Ownership{Owners: []string{"uid:1000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), up, true},
{"unknown method gated", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), unkwn, true},
{"primary gid owner", Ownership{Owners: []string{"gid:5000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000, GID: 5000}), up, false},
{"group-name owner via resolver", Ownership{Owners: []string{"group:admins"}}, Ownership{},
mockResolver{names: map[string]uint32{"admins": 5000}, gids: map[uint32]struct{}{5000: {}}},
ctxWith(Identity{UID: 2000, GID: 42}), up, false},
{"windows sid owner", Ownership{Owners: []string{"sid:S-1-5-21-9"}}, Ownership{}, nil,
ctxWith(Identity{SID: "S-1-5-21-9"}), up, false},
{"windows group-sid owner", Ownership{Owners: []string{"sid:S-1-5-32-544"}}, Ownership{}, nil,
ctxWith(Identity{SID: "S-1-5-21-1", Groups: []string{"S-1-5-32-544"}}), up, false},
{"windows elevated privileged", Ownership{}, Ownership{}, nil,
ctxWith(Identity{SID: "S-1-5-21-1", Elevated: true}), up, false},
// Profile tier (handler self-authorizes, bypass).
{"list bypasses gate", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), list, false},
{"switch-profile bypasses gate", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), switchp, false},
// Owner tier (daemon-wide ownership), independent of the active profile.
{"down by daemon owner allowed", Ownership{Owners: []string{"uid:9"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), down, false},
{"down by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), down, true},
{"status by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), statusm, true},
{"add by daemon owner allowed", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), addp, false},
{"add by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), addp, true},
{"owner-tier TOFU claims unowned daemon", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), down, false},
// Owner-set mutations gate on daemon ownership, not the active profile.
{"add-owner by daemon owner allowed", Ownership{Owners: []string{"uid:9"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), addowner, false},
{"add-owner by active-profile owner (non daemon owner) denied", Ownership{Owners: []string{"uid:2000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), addowner, true},
{"share by active-profile owner (non daemon owner) denied", Ownership{Owners: []string{"uid:2000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), sharep, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &Interceptor{policy: &mockPolicy{o: tt.own, daemon: tt.daemon}, resolver: tt.resolver, selfUID: selfUID}
err := i.authorize(tt.ctx, tt.method)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, codes.PermissionDenied, status.Code(err))
} else {
assert.NoError(t, err)
}
})
}
}
// TestInterceptorForwardedIdentity verifies the JSON-gateway trust model: a
// self/privileged transport peer (the loopback gateway) may forward a real
// client identity, but a non-privileged caller cannot forge it.
func TestInterceptorForwardedIdentity(t *testing.T) {
const selfUID = 4000
owners := Ownership{Owners: []string{"uid:1000"}}
withFwd := func(peerUID, fwdUID uint32) context.Context {
ctx := ctxWith(Identity{UID: peerUID})
return metadata.NewIncomingContext(ctx, metadata.Pairs(mdFwdUID, itoa(fwdUID)))
}
// Gateway forwards a non-owner client: denied as that client.
i := &Interceptor{policy: &mockPolicy{o: owners}, selfUID: selfUID}
assert.Error(t, i.authorize(withFwd(selfUID, 2000), up))
// Gateway forwards the owner: allowed.
assert.NoError(t, i.authorize(withFwd(selfUID, 1000), up))
// A non-privileged direct caller's forwarded metadata: denied
assert.Error(t, i.authorize(withFwd(2000, 1000), up))
}
func itoa(u uint32) string {
return strconv.FormatUint(uint64(u), 10)
}
// TestInterceptorTOFU verifies an unowned, non-shared profile is claimed by the
// first non-privileged caller, and a different caller is then denied.
func TestInterceptorTOFU(t *testing.T) {
policy := &mockPolicy{o: Ownership{}} // unowned
i := &Interceptor{policy: policy, resolver: nil, selfUID: 4000}
// First caller (uid 1000) claims via TOFU.
err := i.authorize(ctxWith(Identity{UID: 1000}), up)
assert.NoError(t, err)
assert.True(t, policy.claimed, "first caller should claim ownership")
assert.Equal(t, []string{"uid:1000"}, policy.o.Owners)
// A different caller is now denied (profile owned by uid 1000).
err = i.authorize(ctxWith(Identity{UID: 2000}), up)
assert.Error(t, err)
assert.Equal(t, codes.PermissionDenied, status.Code(err))
}

View File

@@ -0,0 +1,42 @@
//go:build darwin || freebsd
package ipcauth
import (
"fmt"
"net"
"golang.org/x/sys/unix"
)
// PeerIdentity reads the kernel-authenticated identity of the process on the
// other end of a Unix socket connection via LOCAL_PEERCRED (xucred). xucred
// carries the uid and primary group.
func PeerIdentity(c net.Conn) (Identity, error) {
uc, ok := c.(*net.UnixConn)
if !ok {
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
}
raw, err := uc.SyscallConn()
if err != nil {
return Identity{}, fmt.Errorf("raw conn: %w", err)
}
var cred *unix.Xucred
var credErr error
if err := raw.Control(func(fd uintptr) {
cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
}); err != nil {
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
}
if credErr != nil {
return Identity{}, fmt.Errorf("LOCAL_PEERCRED: %w", credErr)
}
id := Identity{UID: cred.Uid}
// Groups[0] is the effective (primary) GID; guard against an empty list.
if cred.Ngroups > 0 {
id.GID = cred.Groups[0]
}
return id, nil
}

View File

@@ -0,0 +1,41 @@
//go:build linux
package ipcauth
import (
"fmt"
"net"
"golang.org/x/sys/unix"
)
// PeerIdentity reads the kernel-authenticated identity of the process on the
// other end of a Unix socket connection via SO_PEERCRED. The credentials are
// captured by the kernel at connect() time and cannot be spoofed or changed for
// the life of the connection.
func PeerIdentity(c net.Conn) (Identity, error) {
uc, ok := c.(*net.UnixConn)
if !ok {
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
}
raw, err := uc.SyscallConn()
if err != nil {
return Identity{}, fmt.Errorf("raw conn: %w", err)
}
var cred *unix.Ucred
var credErr error
if err := raw.Control(func(fd uintptr) {
cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
}); err != nil {
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
}
if credErr != nil {
return Identity{}, fmt.Errorf("SO_PEERCRED: %w", credErr)
}
return Identity{
UID: cred.Uid,
GID: cred.Gid,
}, nil
}

View File

@@ -0,0 +1,16 @@
//go:build !linux && !darwin && !freebsd
package ipcauth
import (
"fmt"
"net"
"runtime"
)
// PeerIdentity is unimplemented on platforms without a Unix-socket peer-credential
// primitive. Windows derives identity from the named-pipe client token instead
// (see the Windows transport credentials), so it never calls this.
func PeerIdentity(net.Conn) (Identity, error) {
return Identity{}, fmt.Errorf("peer credential check not supported on %s", runtime.GOOS)
}

View File

@@ -0,0 +1,114 @@
//go:build linux || darwin || freebsd
package ipcauth
import (
"context"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
// TestPeerIdentity_MatchesCurrentProcess connects to a real Unix socket and
// verifies the extracted UID/GID match the running process (both ends are us).
func TestPeerIdentity_MatchesCurrentProcess(t *testing.T) {
sock := filepath.Join(t.TempDir(), "peer.sock")
ln, err := net.Listen("unix", sock)
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
type result struct {
id Identity
err error
}
done := make(chan result, 1)
go func() {
c, aerr := ln.Accept()
if aerr != nil {
done <- result{err: aerr}
return
}
defer func() { _ = c.Close() }()
id, ierr := PeerIdentity(c)
done <- result{id: id, err: ierr}
}()
client, err := net.Dial("unix", sock)
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
res := <-done
require.NoError(t, res.err)
assert.Equal(t, uint32(os.Getuid()), res.id.UID, "UID should match current process")
assert.Equal(t, uint32(os.Getgid()), res.id.GID, "primary GID should match current process")
}
// TestPeerIdentity_NonUnixConn rejects non-Unix connections (fail closed).
func TestPeerIdentity_NonUnixConn(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
done := make(chan error, 1)
go func() {
c, aerr := ln.Accept()
if aerr != nil {
done <- aerr
return
}
defer func() { _ = c.Close() }()
_, ierr := PeerIdentity(c)
done <- ierr
}()
client, err := net.Dial("tcp", ln.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
assert.Error(t, <-done, "PeerIdentity must reject a non-Unix connection")
}
// TestGRPCRoundTrip_ServerCredsClientInsecure proves the transport contract end
// to end: a gRPC server using the peercred transport credentials still serves a
// plain insecure client (the CLI never changed), and the caller's kernel
// identity reaches the handler via IdentityFromContext.
func TestGRPCRoundTrip_ServerCredsClientInsecure(t *testing.T) {
sock := filepath.Join(t.TempDir(), "rt.sock")
ln, err := net.Listen("unix", sock)
require.NoError(t, err)
var gotUID uint32
var gotOK bool
srv := grpc.NewServer(
grpc.Creds(NewTransportCredentials()),
grpc.UnaryInterceptor(func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) {
id, ok := IdentityFromContext(ctx)
gotUID, gotOK = id.UID, ok
return h(ctx, req)
}),
)
healthpb.RegisterHealthServer(srv, health.NewServer())
go func() { _ = srv.Serve(ln) }()
t.Cleanup(srv.Stop)
conn, err := grpc.NewClient("unix://"+sock, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{})
require.NoError(t, err, "insecure client must reach the peercred server")
assert.True(t, gotOK, "handler must see a peer identity")
assert.Equal(t, uint32(os.Getuid()), gotUID, "handler must see the caller's UID")
}

View File

@@ -0,0 +1,135 @@
package ipcauth
import "sync"
const servicePath = "/daemon.DaemonService/"
// ProfilePolicy exposes ownership to the interceptor. The daemon server
// implements it. ConfigAdapter bridges the gap because the gRPC server (and its
// interceptor) is constructed before the server instance exists.
type ProfilePolicy interface {
// ActiveProfileOwnership returns the active profile's ownership policy.
ActiveProfileOwnership() Ownership
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for
// id when it has no owners and is not shared (trust-on-first-use), and
// reports whether id is now an owner. A false return means the profile was
// already owned or shared or another caller won the claim.
ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error)
// DaemonOwnership returns the daemon-wide ownership policy that governs the
// owner-tier RPCs and the default profile.
DaemonOwnership() Ownership
// ClaimDaemonOwnerIfUnowned atomically claims daemon-wide ownership for id
// when the daemon is unowned and not shared (trust-on-first-use).
ClaimDaemonOwnerIfUnowned(id Identity) (bool, error)
}
// ownersAuthorizedMethods gate on the daemon-wide owner set (or root): daemon-level
// ops independent of any profile. The owner-set mutations (AddOwner, ShareProfile,
// ResetOwner) must gate here, not on the active profile, else a per-profile owner
// could escalate via `owner add`. ResetOwner also requires root in its handler.
var ownersAuthorizedMethods = map[string]bool{
servicePath + "AddProfile": true,
servicePath + "Down": true,
servicePath + "Status": true,
servicePath + "AddOwner": true,
servicePath + "ShareProfile": true,
servicePath + "ResetOwner": true,
}
// handlerAuthorizedMethods bypass the ownership gate (identity still required)
// and let the handler authorize. GetActiveProfile bypasses only to return
// public metadata any local user may read.
var handlerAuthorizedMethods = map[string]bool{
servicePath + "ListProfiles": true,
servicePath + "RemoveProfile": true,
servicePath + "RenameProfile": true,
servicePath + "SwitchProfile": true,
servicePath + "GetActiveProfile": true,
}
// auditMethods are worth an audit log line. Denials are always logged.
var auditMethods = map[string]bool{
servicePath + "GetConfig": true,
servicePath + "SetConfig": true,
servicePath + "Login": true,
servicePath + "WaitSSOLogin": true,
servicePath + "RequestJWTAuth": true,
servicePath + "WaitJWTToken": true,
servicePath + "StartCapture": true,
servicePath + "StartBundleCapture": true,
servicePath + "DebugBundle": true,
servicePath + "ExposeService": true,
servicePath + "Up": true,
servicePath + "Down": true,
servicePath + "SelectNetworks": true,
servicePath + "DeselectNetworks": true,
servicePath + "SwitchProfile": true,
servicePath + "TriggerUpdate": true,
servicePath + "Logout": true,
servicePath + "CleanState": true,
servicePath + "DeleteState": true,
servicePath + "AddOwner": true,
servicePath + "ResetOwner": true,
servicePath + "ShareProfile": true,
}
// ConfigAdapter is a ProfilePolicy whose backend is set lazily, once the daemon
// server instance is created. Until then it reports an unowned profile
// (Ownership zero value), so non-privileged callers are denied.
type ConfigAdapter struct {
mu sync.RWMutex
backend ProfilePolicy
}
// SetBackend installs the real policy. Must be called before serving RPCs.
func (a *ConfigAdapter) SetBackend(backend ProfilePolicy) {
a.mu.Lock()
defer a.mu.Unlock()
a.backend = backend
}
// ActiveProfileOwnership delegates to the backend, or reports an unowned profile
// when no backend is set yet.
func (a *ConfigAdapter) ActiveProfileOwnership() Ownership {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return Ownership{}
}
return a.backend.ActiveProfileOwnership()
}
// ClaimActiveProfileOwnerIfUnowned delegates to the backend. Before the backend
// is set it cannot claim, so it reports not-owned (fail closed).
func (a *ConfigAdapter) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return false, nil
}
return a.backend.ClaimActiveProfileOwnerIfUnowned(id)
}
// DaemonOwnership delegates to the backend, reporting unowned when none is set.
func (a *ConfigAdapter) DaemonOwnership() Ownership {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return Ownership{}
}
return a.backend.DaemonOwnership()
}
// ClaimDaemonOwnerIfUnowned delegates to the backend. Before the backend is set
// it cannot claim, so it reports not-owned (fail closed).
func (a *ConfigAdapter) ClaimDaemonOwnerIfUnowned(id Identity) (bool, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return false, nil
}
return a.backend.ClaimDaemonOwnerIfUnowned(id)
}

View File

@@ -0,0 +1,54 @@
package ipcauth
import (
"strconv"
"strings"
)
// PrincipalKind is the type of an owner principal.
type PrincipalKind string
const (
KindUID PrincipalKind = "uid" // Unix user ID
KindGID PrincipalKind = "gid" // Unix group ID
KindGroup PrincipalKind = "group" // Unix group name (NSS-resolved)
KindSID PrincipalKind = "sid" // Windows user or group SID
)
// Principal is a parsed owner entry from a profile's Owners list.
type Principal struct {
Kind PrincipalKind
Value string
}
// ParsePrincipal parses a "kind:value" owner string. Returns false for empty
// values or unknown kinds so malformed entries are ignored rather than trusted.
func ParsePrincipal(s string) (Principal, bool) {
kind, value, ok := strings.Cut(s, ":")
if !ok || value == "" {
return Principal{}, false
}
switch PrincipalKind(kind) {
case KindUID, KindGID, KindGroup, KindSID:
return Principal{Kind: PrincipalKind(kind), Value: value}, true
default:
return Principal{}, false
}
}
// UIDPrincipal builds the owner string for a Unix user ID.
func UIDPrincipal(uid uint32) string {
return string(KindUID) + ":" + strconv.FormatUint(uint64(uid), 10)
}
// SIDPrincipal builds the owner string for a Windows SID.
func SIDPrincipal(sid string) string { return string(KindSID) + ":" + sid }
// OwnerPrincipalForIdentity returns the self-ownership principal for an identity:
// the user's UID on Unix, or the user's SID on Windows.
func OwnerPrincipalForIdentity(id Identity) string {
if id.IsWindows() {
return SIDPrincipal(id.SID)
}
return UIDPrincipal(id.UID)
}

View File

@@ -0,0 +1,71 @@
//go:build !windows
package ipcauth
import (
"strconv"
"sync"
"time"
"github.com/netbirdio/netbird/client/internal/shell"
)
const groupCacheTTL = 30 * time.Second
// NewDefaultGroupResolver returns an NSS-aware group resolver, owners resolve
// correctly for LDAP/AD users under CGO_ENABLED=0. Results are cached briefly.
func NewDefaultGroupResolver() GroupResolver {
return &nssResolver{byUID: make(map[uint32]gidCacheEntry)}
}
type gidCacheEntry struct {
gids map[uint32]struct{}
at time.Time
}
type nssResolver struct {
mu sync.Mutex
byUID map[uint32]gidCacheEntry
}
func (r *nssResolver) CallerGIDs(id Identity) map[uint32]struct{} {
r.mu.Lock()
defer r.mu.Unlock()
if e, ok := r.byUID[id.UID]; ok && time.Since(e.at) < groupCacheTTL {
return e.gids
}
gids := resolveGIDs(id.UID)
r.byUID[id.UID] = gidCacheEntry{gids: gids, at: time.Now()}
return gids
}
func resolveGIDs(uid uint32) map[uint32]struct{} {
out := make(map[uint32]struct{})
u, err := shell.GetUserFromGetent(strconv.FormatUint(uint64(uid), 10))
if err != nil {
return out
}
ids, err := shell.GroupIdsWithFallback(u)
if err != nil {
return out
}
for _, s := range ids {
if g, err := strconv.ParseUint(s, 10, 32); err == nil {
out[uint32(g)] = struct{}{}
}
}
return out
}
func (r *nssResolver) GroupNameGID(name string) (uint32, bool) {
g, err := shell.LookupGroupWithGetent(name)
if err != nil {
return 0, false
}
gid, err := strconv.ParseUint(g.Gid, 10, 32)
if err != nil {
return 0, false
}
return uint32(gid), true
}

View File

@@ -0,0 +1,10 @@
//go:build windows
package ipcauth
// NewDefaultGroupResolver returns nil on Windows: group authorization uses the
// group SIDs carried in the client token (see the Windows transport
// credentials).
func NewDefaultGroupResolver() GroupResolver {
return nil
}

View File

@@ -813,19 +813,14 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
}
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
// or the zero value when no deadline is tracked. A deadline that has already
// slipped into the past reports as "none": once the session has expired it is
// no longer a meaningful countdown, and the sessionwatch.Watcher does not
// arm a timer at the deadline itself to clear it (only the two pre-expiry
// warnings). Without this guard the UI would keep painting a stale
// "expires in …" against a moment that has passed until the next login,
// extend, or teardown rewrote the value.
// or the zero value when no deadline is tracked. A deadline in the past is
// returned as-is: it means the session has expired, and consumers (tray row,
// CLI status) render it as "expired" rather than hiding it — masking it as
// "none" would blank the UI at the exact moment it should say the session
// ended.
func (d *Status) GetSessionExpiresAt() time.Time {
d.mux.Lock()
defer d.mux.Unlock()
if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
return time.Time{}
}
return d.sessionExpiresAt
}

View File

@@ -102,6 +102,11 @@ type ConfigInput struct {
DNSLabels domain.List
MTU *uint16
// Owners replaces the profile's owner principal list when non-nil.
// Shared replaces the profile's shared flag when non-nil.
Owners []string
Shared *bool
}
// Config Configuration type
@@ -184,6 +189,16 @@ type Config struct {
MTU uint16
// Owners lists the principals allowed to control this profile over the local
// IPC, as typed strings: "uid:1000", "gid:1000", "group:netbird-admins"
// (Unix, NSS-resolved) or "sid:S-1-5-..." (Windows user or group SID). Empty
// with Shared=false means the profile is owned by nobody yet, until claimed
Owners []string `json:"Owners,omitempty"`
// Shared, when true, lets any authenticated local caller control this profile
// (opt-in). Takes precedence over Owners.
Shared bool `json:"Shared,omitempty"`
// policy is the MDM policy that produced the currently-set values for
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
// and reset on every apply() invocation. Never persisted to disk.
@@ -642,6 +657,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if input.Owners != nil && !slices.Equal(config.Owners, input.Owners) {
log.Infof("updating profile owners to %v", input.Owners)
config.Owners = input.Owners
updated = true
}
if input.Shared != nil && *input.Shared != config.Shared {
log.Infof("updating profile shared flag to %t", *input.Shared)
config.Shared = *input.Shared
updated = true
}
// MDM is the last override layer: any key present in the policy
// supersedes defaults, on-disk config, env vars and CLI input.
config.applyMDMPolicy(loadMDMPolicy())

View File

@@ -296,7 +296,7 @@ func (s *ServiceManager) DefaultProfilePath() string {
// The returned Profile carries the freshly-generated ID so callers can
// show it to the user (and so the gRPC AddProfileResponse can include
// it).
func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) {
func (s *ServiceManager) AddProfile(displayName, username string, owners []string) (*Profile, error) {
configDir, err := s.getConfigDir(username)
if err != nil {
return nil, fmt.Errorf("failed to get config directory: %w", err)
@@ -318,6 +318,9 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err
return nil, fmt.Errorf("failed to create new config: %w", err)
}
cfg.Name = displayName
// owners auto-isolates the new profile to its creator (nil = unowned, so the
// eventual first caller claims it via trust-on-first-use).
cfg.Owners = owners
if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
return nil, fmt.Errorf("failed to write profile config: %w", err)

View File

@@ -32,7 +32,7 @@ func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
func TestServiceProfile_ExactID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
created, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
got, err := sm.ResolveProfile(created.ID.String(), username)
@@ -44,7 +44,7 @@ func TestServiceProfile_ExactID(t *testing.T) {
func TestServiceProfile_IDPrefix(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
created, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
prefix := created.ID[:4]
@@ -75,7 +75,7 @@ func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
func TestServiceProfile_ExactNameUnique(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.AddProfile("work", username)
_, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
got, err := sm.ResolveProfile("work", username)
@@ -86,9 +86,9 @@ func TestServiceProfile_ExactNameUnique(t *testing.T) {
func TestServiceProfile_AmbiguousName(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.AddProfile("work", username)
_, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
_, err = sm.AddProfile("work", username)
_, err = sm.AddProfile("work", username, nil)
require.NoError(t, err)
_, err = sm.ResolveProfile("work", username)
@@ -133,10 +133,10 @@ func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
first, err := sm.AddProfile("work", username)
first, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
second, err := sm.AddProfile("work", username)
second, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID)
assert.Equal(t, "work", second.Name)
@@ -151,7 +151,7 @@ func TestAddProfile_RejectsInvalidNames(t *testing.T) {
strings.Repeat("a", maxProfileNameLen+1), // too long
}
for _, name := range cases {
_, err := sm.AddProfile(name, username)
_, err := sm.AddProfile(name, username, nil)
assert.Error(t, err, "expected error for %q", name)
}
})
@@ -215,7 +215,7 @@ func TestIsValidProfileFilenameStem(t *testing.T) {
func TestRemoveProfile_DeletesStateFile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username)
created, err := sm.AddProfile("work", username, nil)
require.NoError(t, err)
configDir, err := sm.getConfigDir(username)

View File

@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
}
func (r *Route) update(ctx context.Context) error {
resolved, err := r.resolveDomains()
resolved, err := r.resolveDomains(ctx)
if err != nil {
if len(resolved) == 0 {
return fmt.Errorf("resolve domains: %w", err)
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
return nil
}
func (r *Route) resolveDomains() (domainMap, error) {
func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
results := make(chan resolveResult)
go r.resolve(results)
go r.resolve(ctx, results)
resolved := domainMap{}
var merr *multierror.Error
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) {
return resolved, nberrors.FormatErrorOrNil(merr)
}
func (r *Route) resolve(results chan resolveResult) {
func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
var wg sync.WaitGroup
for _, d := range r.route.Domains {
@@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) {
go func(domain domain.Domain) {
defer wg.Done()
ips, err := r.getIPsFromResolver(domain)
ips, err := r.getIPsFromResolver(ctx, domain)
if err != nil {
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
ips, err = net.LookupIP(domain.PunycodeString())
ips, err = lookupHostIPs(ctx, domain)
if err != nil {
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
return
@@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
return
}
// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
if err != nil {
return nil, err
}
ips := make([]net.IP, 0, len(addrs))
for _, addr := range addrs {
ips = append(ips, addr.IP)
}
return ips, nil
}
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
prefixSet := make(map[netip.Prefix]struct{})
for _, prefix := range oldPrefixes {

View File

@@ -3,11 +3,12 @@
package dynamic
import (
"context"
"net"
"github.com/netbirdio/netbird/shared/management/domain"
)
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
return net.LookupIP(domain.PunycodeString())
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
return lookupHostIPs(ctx, domain)
}

View File

@@ -3,6 +3,7 @@
package dynamic
import (
"context"
"fmt"
"net"
"time"
@@ -16,7 +17,7 @@ import (
const dialTimeout = 10 * time.Second
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
if err != nil {
return nil, fmt.Errorf("error while creating private client: %s", err)
@@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
msg := new(dns.Msg)
msg.SetQuestion(fqdn, qtype)
response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
if err != nil {
if queryErr == nil {
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)

View File

@@ -0,0 +1,29 @@
//go:build cgo && !osusergo && !windows
package shell
import "os/user"
// LookupWithGetent with CGO delegates directly to os/user.Lookup.
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through
// the NSS stack natively. If it fails, the user truly doesn't exist and
// getent would also fail.
func LookupWithGetent(username string) (*user.User, error) {
return user.Lookup(username)
}
// CurrentUserWithGetent with CGO delegates directly to os/user.Current.
func CurrentUserWithGetent() (*user.User, error) {
return user.Current()
}
// LookupGroupWithGetent returns the resolved group from either a gid or groupname.
func LookupGroupWithGetent(name string) (*user.Group, error) {
return user.LookupGroup(name)
}
// GroupIdsWithFallback with CGO delegates directly to user.GroupIds.
// libc's getgrouplist handles NSS groups natively.
func GroupIdsWithFallback(u *user.User) ([]string, error) {
return u.GroupIds()
}

View File

@@ -1,6 +1,6 @@
//go:build (!cgo || osusergo) && !windows
package server
package shell
import (
"os"
@@ -10,10 +10,10 @@ import (
log "github.com/sirupsen/logrus"
)
// lookupWithGetent looks up a user by name, falling back to getent if os/user fails.
// LookupWithGetent looks up a user by name, falling back to getent if os/user fails.
// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users.
// getent goes through the host's NSS stack.
func lookupWithGetent(username string) (*user.User, error) {
func LookupWithGetent(username string) (*user.User, error) {
u, err := user.Lookup(username)
if err == nil {
return u, nil
@@ -22,7 +22,7 @@ func lookupWithGetent(username string) (*user.User, error) {
stdErr := err
log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err)
u, _, getentErr := runGetent(username)
u, _, getentErr := runGetentPasswd(username)
if getentErr != nil {
log.Debugf("getent fallback for %q also failed: %v", username, getentErr)
return nil, stdErr
@@ -31,8 +31,26 @@ func lookupWithGetent(username string) (*user.User, error) {
return u, nil
}
// currentUserWithGetent gets the current user, falling back to getent if os/user fails.
func currentUserWithGetent() (*user.User, error) {
// LookupGroupWithGetent returns the resolved group from either a gid or groupname,
// falling back to getent if os/user fails (NSS groups under nocgo).
func LookupGroupWithGetent(name string) (*user.Group, error) {
g, err := user.LookupGroup(name)
if err == nil {
return g, nil
}
stdErr := err
log.Debugf("os/user.LookupGroup(%q) failed, trying getent: %v", name, err)
g, getentErr := runGetentGroup(name)
if getentErr != nil {
log.Debugf("getent fallback for %q also failed: %v", name, getentErr)
return nil, stdErr
}
return g, nil
}
// CurrentUserWithGetent gets the current user, falling back to getent if os/user fails.
func CurrentUserWithGetent() (*user.User, error) {
u, err := user.Current()
if err == nil {
return u, nil
@@ -42,7 +60,7 @@ func currentUserWithGetent() (*user.User, error) {
uid := strconv.Itoa(os.Getuid())
log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err)
u, _, getentErr := runGetent(uid)
u, _, getentErr := runGetentPasswd(uid)
if getentErr != nil {
return nil, stdErr
}
@@ -50,14 +68,14 @@ func currentUserWithGetent() (*user.User, error) {
return u, nil
}
// groupIdsWithFallback gets group IDs for a user via the id command first,
// GroupIdsWithFallback gets group IDs for a user via the id command first,
// falling back to user.GroupIds().
// NOTE: unlike lookupWithGetent/currentUserWithGetent which try stdlib first,
// NOTE: unlike LookupWithGetent/CurrentUserWithGetent which try stdlib first,
// this intentionally tries `id -G` first because without CGO, user.GroupIds()
// only reads /etc/group and silently returns incomplete results for NSS users
// (no error, just missing groups). The id command goes through NSS and returns
// the full set.
func groupIdsWithFallback(u *user.User) ([]string, error) {
func GroupIdsWithFallback(u *user.User) ([]string, error) {
ids, err := runIdGroups(u.Username)
if err == nil {
return ids, nil

View File

@@ -1,4 +1,4 @@
package server
package shell
import (
"os/user"
@@ -15,7 +15,7 @@ func TestLookupWithGetent_CurrentUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
u, err := lookupWithGetent(current.Username)
u, err := LookupWithGetent(current.Username)
require.NoError(t, err)
assert.Equal(t, current.Username, u.Username)
assert.Equal(t, current.Uid, u.Uid)
@@ -23,7 +23,7 @@ func TestLookupWithGetent_CurrentUser(t *testing.T) {
}
func TestLookupWithGetent_NonexistentUser(t *testing.T) {
_, err := lookupWithGetent("nonexistent_user_xyzzy_12345")
_, err := LookupWithGetent("nonexistent_user_xyzzy_12345")
require.Error(t, err, "should fail for nonexistent user")
}
@@ -31,7 +31,7 @@ func TestCurrentUserWithGetent(t *testing.T) {
stdUser, err := user.Current()
require.NoError(t, err)
u, err := currentUserWithGetent()
u, err := CurrentUserWithGetent()
require.NoError(t, err)
assert.Equal(t, stdUser.Uid, u.Uid)
assert.Equal(t, stdUser.Username, u.Username)
@@ -41,7 +41,7 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
groups, err := groupIdsWithFallback(current)
groups, err := GroupIdsWithFallback(current)
require.NoError(t, err)
require.NotEmpty(t, groups, "current user should have at least one group")
@@ -56,7 +56,7 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
func TestGetShellFromGetent_CurrentUser(t *testing.T) {
if runtime.GOOS == "windows" {
// Windows stub always returns empty, which is correct
shell := getShellFromGetent("1000")
shell := GetShellFromGetent("1000")
assert.Empty(t, shell, "Windows stub should return empty")
return
}
@@ -65,9 +65,9 @@ func TestGetShellFromGetent_CurrentUser(t *testing.T) {
require.NoError(t, err)
// getent may not be available on all systems (e.g., macOS without Homebrew getent)
shell := getShellFromGetent(current.Uid)
shell := GetShellFromGetent(current.Uid)
if shell == "" {
t.Log("getShellFromGetent returned empty, getent may not be available")
t.Log("GetShellFromGetent returned empty, getent may not be available")
return
}
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
@@ -78,7 +78,7 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
t.Skip("no root user on Windows")
}
u, err := lookupWithGetent("root")
u, err := LookupWithGetent("root")
if err != nil {
t.Skip("root user not available on this system")
}
@@ -86,25 +86,25 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
}
// TestIntegration_FullLookupChain exercises the complete user lookup chain
// against the real system, testing that all wrappers (lookupWithGetent,
// currentUserWithGetent, groupIdsWithFallback, getShellFromGetent) produce
// against the real system, testing that all wrappers (LookupWithGetent,
// CurrentUserWithGetent, GroupIdsWithFallback, GetShellFromGetent) produce
// consistent and correct results when composed together.
func TestIntegration_FullLookupChain(t *testing.T) {
// Step 1: currentUserWithGetent must resolve the running user.
current, err := currentUserWithGetent()
require.NoError(t, err, "currentUserWithGetent must resolve the running user")
// Step 1: CurrentUserWithGetent must resolve the running user.
current, err := CurrentUserWithGetent()
require.NoError(t, err, "CurrentUserWithGetent must resolve the running user")
require.NotEmpty(t, current.Uid)
require.NotEmpty(t, current.Username)
// Step 2: lookupWithGetent by the same username must return matching identity.
byName, err := lookupWithGetent(current.Username)
// Step 2: LookupWithGetent by the same username must return matching identity.
byName, err := LookupWithGetent(current.Username)
require.NoError(t, err)
assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID")
assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID")
assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home")
// Step 3: groupIdsWithFallback must return at least the primary GID.
groups, err := groupIdsWithFallback(current)
// Step 3: GroupIdsWithFallback must return at least the primary GID.
groups, err := GroupIdsWithFallback(current)
require.NoError(t, err)
require.NotEmpty(t, groups, "user must have at least one group")
@@ -120,10 +120,10 @@ func TestIntegration_FullLookupChain(t *testing.T) {
}
assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid)
// Step 4: getShellFromGetent should either return a valid shell path or empty
// Step 4: GetShellFromGetent should either return a valid shell path or empty
// (empty is OK when getent is not available, e.g. macOS without Homebrew getent).
if runtime.GOOS != "windows" {
shell := getShellFromGetent(current.Uid)
shell := GetShellFromGetent(current.Uid)
if shell != "" {
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}
@@ -131,17 +131,17 @@ func TestIntegration_FullLookupChain(t *testing.T) {
}
// TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via
// lookupWithGetent can have their groups resolved via groupIdsWithFallback,
// LookupWithGetent can have their groups resolved via GroupIdsWithFallback,
// testing the handoff between the two functions as used by the SSH server.
func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
// Simulate the SSH server flow: lookup user, then get their groups.
resolved, err := lookupWithGetent(current.Username)
resolved, err := LookupWithGetent(current.Username)
require.NoError(t, err)
groups, err := groupIdsWithFallback(resolved)
groups, err := GroupIdsWithFallback(resolved)
require.NoError(t, err)
require.NotEmpty(t, groups, "resolved user must have groups")
@@ -156,7 +156,7 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
}
// TestIntegration_ShellLookupChain tests the full shell resolution chain
// (getShellFromPasswd -> getShellFromGetent -> $SHELL -> default) on Unix.
// (getShellFromPasswd -> GetShellFromGetent -> $SHELL -> default) on Unix.
func TestIntegration_ShellLookupChain(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix shell lookup not applicable on Windows")
@@ -165,8 +165,8 @@ func TestIntegration_ShellLookupChain(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
// getUserShell is the top-level function used by the SSH server.
shell := getUserShell(current.Uid)
require.NotEmpty(t, shell, "getUserShell must always return a shell")
// GetUserShell is the top-level function used by the SSH server.
shell := GetUserShell(current.Uid)
require.NotEmpty(t, shell, "GetUserShell must always return a shell")
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}

View File

@@ -1,6 +1,6 @@
//go:build !windows
package server
package shell
import (
"context"
@@ -14,19 +14,26 @@ import (
const getentTimeout = 5 * time.Second
// getShellFromGetent gets a user's login shell via getent by UID.
// GetShellFromGetent gets a user's login shell via getent by UID.
// This is needed even with CGO because getShellFromPasswd reads /etc/passwd
// directly and won't find NSS-provided users there.
func getShellFromGetent(userID string) string {
_, shell, err := runGetent(userID)
func GetShellFromGetent(userID string) string {
_, shell, err := runGetentPasswd(userID)
if err != nil {
return ""
}
return shell
}
// runGetent executes `getent passwd <query>` and returns the user and login shell.
func runGetent(query string) (*user.User, string, error) {
// GetUserFromGetent returns the resolved user from either a uid or username,
// going through the host's NSS stack.
func GetUserFromGetent(query string) (*user.User, error) {
u, _, err := runGetentPasswd(query)
return u, err
}
// runGetentPasswd executes `getent passwd <query>` and returns the user and login shell.
func runGetentPasswd(query string) (*user.User, string, error) {
if !validateGetentInput(query) {
return nil, "", fmt.Errorf("invalid getent input: %q", query)
}
@@ -42,7 +49,24 @@ func runGetent(query string) (*user.User, string, error) {
return parseGetentPasswd(string(out))
}
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell"
// runGetentGroup executes `getent group <query>` and returns the group.
func runGetentGroup(query string) (*user.Group, error) {
if !validateGetentInput(query) {
return nil, fmt.Errorf("invalid getent input: %q", query)
}
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "getent", "group", query).Output()
if err != nil {
return nil, fmt.Errorf("getent group %s: %w", query, err)
}
return parseGetentGroup(string(out))
}
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell".
func parseGetentPasswd(output string) (*user.User, string, error) {
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
if len(fields) < 6 {
@@ -67,6 +91,20 @@ func parseGetentPasswd(output string) (*user.User, string, error) {
}, shell, nil
}
// parseGetentGroup parses getent group output: "group:x:gid:members".
func parseGetentGroup(output string) (*user.Group, error) {
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
if len(fields) < 4 {
return nil, fmt.Errorf("unexpected getent output (need 4+ fields): %q", output)
}
if fields[0] == "" || fields[2] == "" {
return nil, fmt.Errorf("missing required fields in getent output: %q", output)
}
return &user.Group{Gid: fields[2], Name: fields[0]}, nil
}
// validateGetentInput checks that the input is safe to pass to getent or id.
// Allows POSIX usernames, numeric UIDs, and common NSS extensions
// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is

View File

@@ -1,6 +1,6 @@
//go:build !windows
package server
package shell
import (
"os/exec"
@@ -198,7 +198,7 @@ func TestRunGetent_RootUser(t *testing.T) {
t.Skip("getent not available on this system")
}
u, shell, err := runGetent("root")
u, shell, err := runGetentPasswd("root")
require.NoError(t, err)
assert.Equal(t, "root", u.Username)
assert.Equal(t, "0", u.Uid)
@@ -211,7 +211,7 @@ func TestRunGetent_ByUID(t *testing.T) {
t.Skip("getent not available on this system")
}
u, _, err := runGetent("0")
u, _, err := runGetentPasswd("0")
require.NoError(t, err)
assert.Equal(t, "root", u.Username)
assert.Equal(t, "0", u.Uid)
@@ -222,15 +222,15 @@ func TestRunGetent_NonexistentUser(t *testing.T) {
t.Skip("getent not available on this system")
}
_, _, err := runGetent("nonexistent_user_xyzzy_12345")
_, _, err := runGetentPasswd("nonexistent_user_xyzzy_12345")
assert.Error(t, err)
}
func TestRunGetent_InvalidInput(t *testing.T) {
_, _, err := runGetent("")
_, _, err := runGetentPasswd("")
assert.Error(t, err)
_, _, err = runGetent("user\x00name")
_, _, err = runGetentPasswd("user\x00name")
assert.Error(t, err)
}
@@ -239,7 +239,7 @@ func TestRunGetent_NotAvailable(t *testing.T) {
t.Skip("getent is available, can't test missing case")
}
_, _, err := runGetent("root")
_, _, err := runGetentPasswd("root")
assert.Error(t, err, "should fail when getent is not installed")
}
@@ -286,7 +286,7 @@ func TestGetentResultsMatchStdlib(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
getentUser, _, err := runGetent(current.Username)
getentUser, _, err := runGetentPasswd(current.Username)
require.NoError(t, err)
assert.Equal(t, current.Username, getentUser.Username, "username should match")
@@ -303,7 +303,7 @@ func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
getentUser, _, err := runGetent(current.Uid)
getentUser, _, err := runGetentPasswd(current.Uid)
require.NoError(t, err)
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID")
@@ -359,7 +359,7 @@ func TestGetShellFromPasswd_CurrentUser(t *testing.T) {
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
if _, err := exec.LookPath("getent"); err == nil {
_, getentShell, getentErr := runGetent(current.Uid)
_, getentShell, getentErr := runGetentPasswd(current.Uid)
if getentErr == nil && getentShell != "" {
assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent")
}
@@ -403,7 +403,7 @@ func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
continue
}
_, getentShell, err := runGetent(uid)
_, getentShell, err := runGetentPasswd(uid)
if err != nil {
continue
}

View File

@@ -0,0 +1,30 @@
//go:build windows
package shell
import "os/user"
// LookupWithGetent on Windows just delegates to os/user.Lookup.
func LookupWithGetent(username string) (*user.User, error) {
return user.Lookup(username)
}
// CurrentUserWithGetent on Windows just delegates to os/user.Current.
func CurrentUserWithGetent() (*user.User, error) {
return user.Current()
}
// LookupGroupWithGetent on Windows just delegates to os/user.LookupGroup.
func LookupGroupWithGetent(name string) (*user.Group, error) {
return user.LookupGroup(name)
}
// GetShellFromGetent is a no-op on Windows.
func GetShellFromGetent(_ string) string {
return ""
}
// GroupIdsWithFallback on Windows just delegates to u.GroupIds().
func GroupIdsWithFallback(u *user.User) ([]string, error) {
return u.GroupIds()
}

View File

@@ -1,17 +1,14 @@
package server
package shell
import (
"bufio"
"fmt"
"net"
"os"
"os/exec"
"os/user"
"runtime"
"strconv"
"strings"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
)
@@ -22,9 +19,9 @@ const (
powershellExe = "powershell.exe"
)
// getUserShell returns the appropriate shell for the given user ID
// Handles all platform-specific logic and fallbacks consistently
func getUserShell(userID string) string {
// GetUserShell returns the appropriate shell for the given user ID.
// Handles all platform-specific logic and fallbacks consistently.
func GetUserShell(userID string) string {
switch runtime.GOOS {
case "windows":
return getWindowsUserShell()
@@ -56,7 +53,7 @@ func getUnixUserShell(userID string) string {
return shell
}
if shell := getShellFromGetent(userID); shell != "" {
if shell := GetShellFromGetent(userID); shell != "" {
return shell
}
@@ -67,7 +64,7 @@ func getUnixUserShell(userID string) string {
return defaultUnixShell
}
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID.
func getShellFromPasswd(userID string) string {
file, err := os.Open("/etc/passwd")
if err != nil {
@@ -101,8 +98,8 @@ func getShellFromPasswd(userID string) string {
return ""
}
// prepareUserEnv prepares environment variables for user execution
func prepareUserEnv(user *user.User, shell string) []string {
// PrepareUserEnv prepares environment variables for user execution.
func PrepareUserEnv(user *user.User, shell string) []string {
pathValue := "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games"
if runtime.GOOS == "windows" {
pathValue = `C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0`
@@ -117,9 +114,9 @@ func prepareUserEnv(user *user.User, shell string) []string {
}
}
// acceptEnv checks if environment variable from SSH client should be accepted
// This is a whitelist of variables that SSH clients can send to the server
func acceptEnv(envVar string) bool {
// AcceptEnv checks if an environment variable from an SSH client should be accepted.
// This is a whitelist of variables that SSH clients can send to the server.
func AcceptEnv(envVar string) bool {
varName := envVar
if idx := strings.Index(envVar, "="); idx != -1 {
varName = envVar[:idx]
@@ -156,29 +153,3 @@ func acceptEnv(envVar string) bool {
return false
}
// prepareSSHEnv prepares SSH protocol-specific environment variables
// These variables provide information about the SSH connection itself
func prepareSSHEnv(session ssh.Session) []string {
remoteAddr := session.RemoteAddr()
localAddr := session.LocalAddr()
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String())
if err != nil {
remoteHost = remoteAddr.String()
remotePort = "0"
}
localHost, localPort, err := net.SplitHostPort(localAddr.String())
if err != nil {
localHost = localAddr.String()
localPort = strconv.Itoa(InternalSSHPort)
}
return []string{
// SSH_CLIENT format: "client_ip client_port server_port"
fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort),
// SSH_CONNECTION format: "client_ip client_port server_ip server_port"
fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort),
}
}

View File

@@ -233,6 +233,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
deps.SyncResponse = resp
if e := cc.Engine(); e != nil {
deps.RefreshStatus = func() {
e.RunHealthProbes(context.Background(), true)
}
if cm := e.GetClientMetrics(); cm != nil {
deps.ClientMetrics = cm
}

File diff suppressed because it is too large Load Diff

View File

@@ -791,6 +791,78 @@ func local_request_DaemonService_GetActiveProfile_0(ctx context.Context, marshal
return msg, metadata, err
}
func request_DaemonService_AddOwner_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq AddOwnerRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.AddOwner(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_DaemonService_AddOwner_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq AddOwnerRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.AddOwner(ctx, &protoReq)
return msg, metadata, err
}
func request_DaemonService_ResetOwner_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ResetOwnerRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.ResetOwner(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_DaemonService_ResetOwner_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ResetOwnerRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.ResetOwner(ctx, &protoReq)
return msg, metadata, err
}
func request_DaemonService_ShareProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ShareProfileRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.ShareProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_DaemonService_ShareProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ShareProfileRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.ShareProfile(ctx, &protoReq)
return msg, metadata, err
}
func request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq LogoutRequest
@@ -1730,6 +1802,66 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM
}
forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_AddOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/AddOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddOwner"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_DaemonService_AddOwner_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_AddOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_ResetOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ResetOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/ResetOwner"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_DaemonService_ResetOwner_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_ResetOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_ShareProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ShareProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/ShareProfile"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_DaemonService_ShareProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_ShareProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -2581,6 +2713,57 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM
}
forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_AddOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/AddOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddOwner"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_DaemonService_AddOwner_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_AddOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_ResetOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ResetOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/ResetOwner"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_DaemonService_ResetOwner_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_ResetOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_ShareProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ShareProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/ShareProfile"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_DaemonService_ShareProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_DaemonService_ShareProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -2855,6 +3038,9 @@ var (
pattern_DaemonService_RemoveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RemoveProfile"}, ""))
pattern_DaemonService_ListProfiles_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListProfiles"}, ""))
pattern_DaemonService_GetActiveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetActiveProfile"}, ""))
pattern_DaemonService_AddOwner_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "AddOwner"}, ""))
pattern_DaemonService_ResetOwner_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ResetOwner"}, ""))
pattern_DaemonService_ShareProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ShareProfile"}, ""))
pattern_DaemonService_Logout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Logout"}, ""))
pattern_DaemonService_GetFeatures_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetFeatures"}, ""))
pattern_DaemonService_TriggerUpdate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TriggerUpdate"}, ""))
@@ -2904,6 +3090,9 @@ var (
forward_DaemonService_RemoveProfile_0 = runtime.ForwardResponseMessage
forward_DaemonService_ListProfiles_0 = runtime.ForwardResponseMessage
forward_DaemonService_GetActiveProfile_0 = runtime.ForwardResponseMessage
forward_DaemonService_AddOwner_0 = runtime.ForwardResponseMessage
forward_DaemonService_ResetOwner_0 = runtime.ForwardResponseMessage
forward_DaemonService_ShareProfile_0 = runtime.ForwardResponseMessage
forward_DaemonService_Logout_0 = runtime.ForwardResponseMessage
forward_DaemonService_GetFeatures_0 = runtime.ForwardResponseMessage
forward_DaemonService_TriggerUpdate_0 = runtime.ForwardResponseMessage

View File

@@ -104,6 +104,18 @@ service DaemonService {
rpc GetActiveProfile(GetActiveProfileRequest) returns (GetActiveProfileResponse) {}
// AddOwner adds a principal to the active profile's owner list. Requires the
// caller to be privileged (root/administrator) or an existing owner.
rpc AddOwner(AddOwnerRequest) returns (AddOwnerResponse) {}
// ResetOwner clears the active profile's owner list, returning it to the
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
rpc ResetOwner(ResetOwnerRequest) returns (ResetOwnerResponse) {}
// ShareProfile marks the active profile shared (any local caller) or unshared.
// Requires the caller to be an owner or privileged.
rpc ShareProfile(ShareProfileRequest) returns (ShareProfileResponse) {}
// Logout disconnects from the network and deletes the peer from the management server
rpc Logout(LogoutRequest) returns (LogoutResponse) {}
@@ -270,6 +282,10 @@ message UpRequest {
// RPC blocks until the engine is running or gives up, which is the behaviour
// needed by the CLI.
bool async = 4;
// claimOwner, when true, claims ownership of the active profile for the
// calling user (adds their kernel principal to the profile's owner list).
bool claimOwner = 5;
}
message UpResponse {}
@@ -774,6 +790,24 @@ message AddProfileResponse {
string id = 1;
}
message AddOwnerRequest {
// principal is a typed owner string: "uid:1000", "gid:1000",
// "group:netbird-admins" (Unix, NSS-resolved), or "sid:S-1-5-..." (Windows).
string principal = 1;
}
message AddOwnerResponse {}
message ResetOwnerRequest {}
message ResetOwnerResponse {}
message ShareProfileRequest {
bool shared = 1;
}
message ShareProfileResponse {}
message RenameProfileRequest {
string username = 1;
// handle: an exact ID, a unique ID prefix, or a unique display name.

View File

@@ -51,6 +51,9 @@ const (
DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile"
DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles"
DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile"
DaemonService_AddOwner_FullMethodName = "/daemon.DaemonService/AddOwner"
DaemonService_ResetOwner_FullMethodName = "/daemon.DaemonService/ResetOwner"
DaemonService_ShareProfile_FullMethodName = "/daemon.DaemonService/ShareProfile"
DaemonService_Logout_FullMethodName = "/daemon.DaemonService/Logout"
DaemonService_GetFeatures_FullMethodName = "/daemon.DaemonService/GetFeatures"
DaemonService_TriggerUpdate_FullMethodName = "/daemon.DaemonService/TriggerUpdate"
@@ -132,6 +135,15 @@ type DaemonServiceClient interface {
RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error)
ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error)
GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error)
// AddOwner adds a principal to the active profile's owner list. Requires the
// caller to be privileged (root/administrator) or an existing owner.
AddOwner(ctx context.Context, in *AddOwnerRequest, opts ...grpc.CallOption) (*AddOwnerResponse, error)
// ResetOwner clears the active profile's owner list, returning it to the
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
ResetOwner(ctx context.Context, in *ResetOwnerRequest, opts ...grpc.CallOption) (*ResetOwnerResponse, error)
// ShareProfile marks the active profile shared (any local caller) or unshared.
// Requires the caller to be an owner or privileged.
ShareProfile(ctx context.Context, in *ShareProfileRequest, opts ...grpc.CallOption) (*ShareProfileResponse, error)
// Logout disconnects from the network and deletes the peer from the management server
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
GetFeatures(ctx context.Context, in *GetFeaturesRequest, opts ...grpc.CallOption) (*GetFeaturesResponse, error)
@@ -528,6 +540,36 @@ func (c *daemonServiceClient) GetActiveProfile(ctx context.Context, in *GetActiv
return out, nil
}
func (c *daemonServiceClient) AddOwner(ctx context.Context, in *AddOwnerRequest, opts ...grpc.CallOption) (*AddOwnerResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AddOwnerResponse)
err := c.cc.Invoke(ctx, DaemonService_AddOwner_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) ResetOwner(ctx context.Context, in *ResetOwnerRequest, opts ...grpc.CallOption) (*ResetOwnerResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResetOwnerResponse)
err := c.cc.Invoke(ctx, DaemonService_ResetOwner_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) ShareProfile(ctx context.Context, in *ShareProfileRequest, opts ...grpc.CallOption) (*ShareProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ShareProfileResponse)
err := c.cc.Invoke(ctx, DaemonService_ShareProfile_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LogoutResponse)
@@ -742,6 +784,15 @@ type DaemonServiceServer interface {
RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error)
ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error)
GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error)
// AddOwner adds a principal to the active profile's owner list. Requires the
// caller to be privileged (root/administrator) or an existing owner.
AddOwner(context.Context, *AddOwnerRequest) (*AddOwnerResponse, error)
// ResetOwner clears the active profile's owner list, returning it to the
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
ResetOwner(context.Context, *ResetOwnerRequest) (*ResetOwnerResponse, error)
// ShareProfile marks the active profile shared (any local caller) or unshared.
// Requires the caller to be an owner or privileged.
ShareProfile(context.Context, *ShareProfileRequest) (*ShareProfileResponse, error)
// Logout disconnects from the network and deletes the peer from the management server
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
GetFeatures(context.Context, *GetFeaturesRequest) (*GetFeaturesResponse, error)
@@ -887,6 +938,15 @@ func (UnimplementedDaemonServiceServer) ListProfiles(context.Context, *ListProfi
func (UnimplementedDaemonServiceServer) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetActiveProfile not implemented")
}
func (UnimplementedDaemonServiceServer) AddOwner(context.Context, *AddOwnerRequest) (*AddOwnerResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AddOwner not implemented")
}
func (UnimplementedDaemonServiceServer) ResetOwner(context.Context, *ResetOwnerRequest) (*ResetOwnerResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ResetOwner not implemented")
}
func (UnimplementedDaemonServiceServer) ShareProfile(context.Context, *ShareProfileRequest) (*ShareProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ShareProfile not implemented")
}
func (UnimplementedDaemonServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Logout not implemented")
}
@@ -1505,6 +1565,60 @@ func _DaemonService_GetActiveProfile_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _DaemonService_AddOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddOwnerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).AddOwner(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_AddOwner_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).AddOwner(ctx, req.(*AddOwnerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DaemonService_ResetOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResetOwnerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).ResetOwner(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_ResetOwner_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).ResetOwner(ctx, req.(*ResetOwnerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DaemonService_ShareProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ShareProfileRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).ShareProfile(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_ShareProfile_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).ShareProfile(ctx, req.(*ShareProfileRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DaemonService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LogoutRequest)
if err := dec(in); err != nil {
@@ -1873,6 +1987,18 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetActiveProfile",
Handler: _DaemonService_GetActiveProfile_Handler,
},
{
MethodName: "AddOwner",
Handler: _DaemonService_AddOwner_Handler,
},
{
MethodName: "ResetOwner",
Handler: _DaemonService_ResetOwner_Handler,
},
{
MethodName: "ShareProfile",
Handler: _DaemonService_ShareProfile_Handler,
},
{
MethodName: "Logout",
Handler: _DaemonService_Logout_Handler,

393
client/server/ownership.go Normal file
View File

@@ -0,0 +1,393 @@
package server
import (
"context"
"fmt"
"slices"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
// Verify that the daemon Server implements ipcauth.ProfilePolicy.
var _ ipcauth.ProfilePolicy = (*Server)(nil)
// DaemonOwnerStore persists the daemon-wide owner set. The cmd layer implements
// it over service.json. The interface lives here to avoid an import cycle. A nil
// store means the daemon is unowned, so non-privileged callers are denied on the
// default profile.
type DaemonOwnerStore interface {
// Load returns the persisted daemon owner principals and shared flag.
Load() (owners []string, shared bool, err error)
// Save persists the daemon owner principals and shared flag.
Save(owners []string, shared bool) error
}
// SetDaemonOwnerStore installs the owner persistence backend and loads the
// current owner set into memory. Called once by cmd before serving RPCs.
func (s *Server) SetDaemonOwnerStore(store DaemonOwnerStore) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.daemonOwnerStore = store
if store == nil {
return
}
owners, shared, err := store.Load()
if err != nil {
log.Warnf("ownership: cannot load daemon owners, treating as unowned: %v", err)
return
}
s.owners = ipcauth.Ownership{Owners: owners, Shared: shared}
log.Infof("daemon owners loaded: %d principal(s), shared=%t", len(owners), shared)
}
// activeIsDefaultLocked reports whether the active profile is the shared default.
// The default is owned daemon-wide, every other profile by its own per-profile
// owner. Caller must hold s.mutex.
func (s *Server) activeIsDefaultLocked() (bool, error) {
active, err := s.profileManager.GetActiveProfileState()
if err != nil {
return false, fmt.Errorf("get active profile: %w", err)
}
return active.ID == profilemanager.DefaultProfileName, nil
}
// ActiveProfileOwnership returns the ownership the interceptor gates the active
// profile against. The default profile uses the daemon-wide owner set, every
// other profile uses its own collision-free owner (isolated per user).
func (s *Server) ActiveProfileOwnership() ipcauth.Ownership {
s.mutex.Lock()
defer s.mutex.Unlock()
isDefault, err := s.activeIsDefaultLocked()
if err != nil {
log.Warnf("ownership: cannot determine active profile, treating as unowned: %v", err)
return ipcauth.Ownership{}
}
if isDefault {
return s.owners
}
cfg := s.config
if cfg == nil {
loaded, err := s.loadActiveProfileConfigLocked()
if err != nil {
log.Warnf("ownership: cannot load active profile config, treating as unowned: %v", err)
return ipcauth.Ownership{}
}
cfg = loaded
}
return ipcauth.Ownership{Owners: cfg.Owners, Shared: cfg.Shared}
}
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for id
// when it has no owners and is not shared (trust-on-first-use). The default
// profile claims daemon-wide ownership via the owner store, every other profile
// claims its own per-profile owner. Returns whether id is now an owner. Concurrent
// first-callers are serialized by s.mutex.
func (s *Server) ClaimActiveProfileOwnerIfUnowned(id ipcauth.Identity) (bool, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
isDefault, err := s.activeIsDefaultLocked()
if err != nil {
return false, fmt.Errorf("determine active profile: %w", err)
}
if isDefault {
return s.claimDaemonOwnerLocked(id)
}
cfg := s.config
if cfg == nil {
loaded, err := s.loadActiveProfileConfigLocked()
if err != nil {
return false, fmt.Errorf("load active profile config: %w", err)
}
cfg = loaded
}
if len(cfg.Owners) > 0 || cfg.Shared {
return false, nil // already owned or shared
}
cfg.Owners = []string{ipcauth.OwnerPrincipalForIdentity(id)}
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
cfg.Owners = nil // revert in-memory on persistence failure
return false, fmt.Errorf("persist claimed ownership: %w", err)
}
s.config = cfg
log.Infof("profile ownership claimed by %s (trust-on-first-use)", id)
return true, nil
}
// DaemonOwnership returns the daemon-wide owner set. It governs the owner-tier
// RPCs (Add, Down, Status) and the default profile.
func (s *Server) DaemonOwnership() ipcauth.Ownership {
s.mutex.Lock()
defer s.mutex.Unlock()
return s.owners
}
// ClaimDaemonOwnerIfUnowned claims daemon-wide ownership for id when the daemon
// is unowned and unshared (trust-on-first-use).
func (s *Server) ClaimDaemonOwnerIfUnowned(id ipcauth.Identity) (bool, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
return s.claimDaemonOwnerLocked(id)
}
// claimDaemonOwnerLocked claims daemon-wide ownership for id when the daemon is
// unowned and unshared, persisting via the owner store. Caller must hold s.mutex.
func (s *Server) claimDaemonOwnerLocked(id ipcauth.Identity) (bool, error) {
if s.daemonOwnerStore == nil {
return false, nil // no store, cannot claim, fail closed
}
if len(s.owners.Owners) > 0 || s.owners.Shared {
return false, nil // already owned or shared
}
principal := ipcauth.OwnerPrincipalForIdentity(id)
if err := s.daemonOwnerStore.Save([]string{principal}, false); err != nil {
return false, fmt.Errorf("persist daemon owner claim: %w", err)
}
s.owners = ipcauth.Ownership{Owners: []string{principal}}
log.Infof("daemon ownership claimed by %s (trust-on-first-use)", id)
return true, nil
}
// addDaemonOwnerLocked adds id's principal to the daemon owner set and persists.
// Idempotent, and a no-op for privileged callers. Caller must hold s.mutex.
func (s *Server) addDaemonOwnerLocked(id ipcauth.Identity) error {
if id.IsPrivileged() {
return nil
}
if s.daemonOwnerStore == nil {
return fmt.Errorf("daemon owner store unavailable")
}
principal := ipcauth.OwnerPrincipalForIdentity(id)
if slices.Contains(s.owners.Owners, principal) {
return nil
}
next := append(slices.Clone(s.owners.Owners), principal)
if err := s.daemonOwnerStore.Save(next, s.owners.Shared); err != nil {
return err
}
s.owners.Owners = next
return nil
}
// activeProfileConfigPathLocked resolves the active profile's config file path.
func (s *Server) activeProfileConfigPathLocked() (string, error) {
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
return "", fmt.Errorf("get active profile: %w", err)
}
path, err := activeProf.FilePath()
if err != nil {
return "", fmt.Errorf("resolve active profile path: %w", err)
}
return path, nil
}
// loadActiveProfileConfigLocked reads the active profile's config from disk.
func (s *Server) loadActiveProfileConfigLocked() (*profilemanager.Config, error) {
path, err := s.activeProfileConfigPathLocked()
if err != nil {
return nil, err
}
return profilemanager.GetConfig(path)
}
// persistActiveProfileConfigLocked writes cfg to the active profile's config file.
func (s *Server) persistActiveProfileConfigLocked(cfg *profilemanager.Config) error {
path, err := s.activeProfileConfigPathLocked()
if err != nil {
return err
}
return util.WriteJson(context.Background(), path, cfg)
}
// claimForCallerLocked adds the caller's principal to the active profile's owner
// set (if absent) and persists. No-op for privileged callers. For the default
// profile it adds to the daemon-wide owners, for any other profile it adds to
// that profile's per-profile owners. Caller must hold s.mutex.
func (s *Server) claimForCallerLocked(id ipcauth.Identity, cfg *profilemanager.Config) error {
if id.IsPrivileged() {
return nil
}
isDefault, err := s.activeIsDefaultLocked()
if err != nil {
return err
}
if isDefault {
return s.addDaemonOwnerLocked(id)
}
principal := ipcauth.OwnerPrincipalForIdentity(id)
if slices.Contains(cfg.Owners, principal) {
return nil
}
cfg.Owners = append(cfg.Owners, principal)
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
cfg.Owners = cfg.Owners[:len(cfg.Owners)-1] // revert on failure
return err
}
s.config = cfg
return nil
}
// authorizeTargetProfile authorizes a caller to operate on a specific target
// profile. It MUST be called after bindCallerUsername, which enforces the legacy
// per-username-directory guard. this layers the collision-free Owners field on
// top of it:
//
// - Privileged callers (root / elevated-admin) may operate on any profile.
// - If the target has Owners (or is Shared), they are authoritative. This
// disambiguates users whose sanitized usernames collide.
// - If the target is unowned (a legacy profile predating ownership), passing
// the username guard is sufficient and then the profile is claimed.
//
// Caller must hold s.mutex (it may persist an ownership claim).
func (s *Server) authorizeTargetProfile(ctx context.Context, target *profilemanager.Profile, claim bool) error {
id, ok := ipcauth.IdentityFromContext(ctx)
if !ok {
return gstatus.Error(codes.PermissionDenied, "caller identity could not be verified")
}
if id.IsPrivileged() {
return nil
}
// The default profile is governed by the daemon-wide owners (all owners may
// use it), not a per-profile owner. Authorize against s.owners and never stamp.
if target.ID == profilemanager.DefaultProfileName {
if ipcauth.Authorize(s.owners, id, s.groupResolver) {
return nil
}
return gstatus.Errorf(codes.PermissionDenied,
"not authorized to use the default profile (caller %s is not a daemon owner)", id)
}
path, err := target.FilePath()
if err != nil {
return fmt.Errorf("resolve target profile path: %w", err)
}
cfg, err := profilemanager.GetConfig(path)
if err != nil {
return fmt.Errorf("load target profile config: %w", err)
}
ownership := ipcauth.Ownership{Owners: cfg.Owners, Shared: cfg.Shared}
// Owned or shared: the Owners field is authoritative (collision-free).
if len(ownership.Owners) > 0 || ownership.Shared {
if ipcauth.Authorize(ownership, id, s.groupResolver) {
return nil
}
return gstatus.Errorf(codes.PermissionDenied,
"not authorized to operate on profile %q (owned by another principal)", target.Name)
}
// Unowned legacy profile: the username guard authorizes. Stamp the caller
// as owner so future access is collision-free.
if claim {
principal := ipcauth.OwnerPrincipalForIdentity(id)
cfg.Owners = []string{principal}
if err := util.WriteJson(context.Background(), path, cfg); err != nil {
return fmt.Errorf("persist profile ownership claim: %w", err)
}
log.Infof("profile %q (%s) claimed by %s on first access (trust-on-first-use)", target.Name, target.ID, id)
}
return nil
}
// requireDaemonOwnerLocked fails closed unless the caller is a daemon owner or
// privileged. Defense in depth for the owner-set mutations, which the interceptor
// owner tier already gates. Caller must hold s.mutex.
func (s *Server) requireDaemonOwnerLocked(ctx context.Context) error {
id, ok := ipcauth.IdentityFromContext(ctx)
if !ok {
return gstatus.Error(codes.PermissionDenied, "caller identity could not be verified")
}
if id.IsPrivileged() || ipcauth.Authorize(s.owners, id, s.groupResolver) {
return nil
}
return gstatus.Error(codes.PermissionDenied, "not authorized: managing daemon owners requires a daemon owner or root/administrator")
}
// AddOwner adds a principal to the daemon-wide owner set. The owner tier gates
// this, the handler re-checks (defense in depth), validates, and persists.
func (s *Server) AddOwner(ctx context.Context, msg *proto.AddOwnerRequest) (*proto.AddOwnerResponse, error) {
principal := msg.GetPrincipal()
if _, ok := ipcauth.ParsePrincipal(principal); !ok {
return nil, gstatus.Errorf(codes.InvalidArgument, "invalid owner principal %q (expected uid:/gid:/group:/sid:)", principal)
}
s.mutex.Lock()
defer s.mutex.Unlock()
if err := s.requireDaemonOwnerLocked(ctx); err != nil {
return nil, err
}
if s.daemonOwnerStore == nil {
return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable")
}
if slices.Contains(s.owners.Owners, principal) {
return &proto.AddOwnerResponse{}, nil
}
next := append(slices.Clone(s.owners.Owners), principal)
if err := s.daemonOwnerStore.Save(next, s.owners.Shared); err != nil {
return nil, fmt.Errorf("persist owner: %w", err)
}
s.owners.Owners = next
log.Infof("added daemon owner %q", principal)
return &proto.AddOwnerResponse{}, nil
}
// ResetOwner clears the daemon-wide owner set (and shared flag), returning the
// daemon to the unowned state so the next caller re-claims via trust-on-first-use.
// Privileged-only, so co-owners cannot evict each other.
func (s *Server) ResetOwner(ctx context.Context, _ *proto.ResetOwnerRequest) (*proto.ResetOwnerResponse, error) {
id, ok := ipcauth.IdentityFromContext(ctx)
if !ok || !id.IsPrivileged() {
return nil, gstatus.Error(codes.PermissionDenied, "reset-owner requires root/administrator")
}
s.mutex.Lock()
defer s.mutex.Unlock()
if s.daemonOwnerStore == nil {
return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable")
}
if err := s.daemonOwnerStore.Save(nil, false); err != nil {
return nil, fmt.Errorf("persist owner reset: %w", err)
}
s.owners = ipcauth.Ownership{}
log.Infof("daemon owner list reset, next caller will re-claim (trust-on-first-use)")
return &proto.ResetOwnerResponse{}, nil
}
// ShareProfile marks the daemon shared or unshared. When shared, any authenticated
// local caller may control the daemon and its default profile. The owner tier
// gates this, the handler re-checks (defense in depth).
func (s *Server) ShareProfile(ctx context.Context, msg *proto.ShareProfileRequest) (*proto.ShareProfileResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
if err := s.requireDaemonOwnerLocked(ctx); err != nil {
return nil, err
}
if s.daemonOwnerStore == nil {
return nil, gstatus.Error(codes.Unavailable, "daemon owner store unavailable")
}
if err := s.daemonOwnerStore.Save(s.owners.Owners, msg.GetShared()); err != nil {
return nil, fmt.Errorf("persist shared flag: %w", err)
}
s.owners.Shared = msg.GetShared()
log.Infof("daemon shared flag set to %t", msg.GetShared())
return &proto.ShareProfileResponse{}, nil
}

View File

@@ -0,0 +1,210 @@
package server
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
// fakeOwnerStore is an in-memory server.DaemonOwnerStore for tests.
type fakeOwnerStore struct {
owners []string
shared bool
}
func (f *fakeOwnerStore) Load() ([]string, bool, error) { return f.owners, f.shared, nil }
func (f *fakeOwnerStore) Save(o []string, s bool) error { f.owners, f.shared = o, s; return nil }
// useTempProfileDirs points the profilemanager globals at a temp dir so
// GetActiveProfileState resolves to the default profile without touching /etc.
func useTempProfileDirs(t *testing.T) {
t.Helper()
tempDir := t.TempDir()
origDir := profilemanager.DefaultConfigPathDir
origActive := profilemanager.ActiveProfileStatePath
origDefault := profilemanager.DefaultConfigPath
profilemanager.ConfigDirOverride = tempDir
profilemanager.DefaultConfigPathDir = tempDir
profilemanager.ActiveProfileStatePath = filepath.Join(tempDir, "active_profile.json")
profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
t.Cleanup(func() {
profilemanager.DefaultConfigPathDir = origDir
profilemanager.ActiveProfileStatePath = origActive
profilemanager.DefaultConfigPath = origDefault
profilemanager.ConfigDirOverride = ""
})
}
// TestDaemonOwnerPolicyDefaultProfile exercises the daemon-wide owner branch that
// governs the default profile: TOFU claim, add and reset, all via the store.
func TestDaemonOwnerPolicyDefaultProfile(t *testing.T) {
useTempProfileDirs(t)
store := &fakeOwnerStore{}
s := &Server{profileManager: &profilemanager.ServiceManager{}, groupResolver: ipcauth.NewDefaultGroupResolver()}
s.SetDaemonOwnerStore(store)
// Active profile is the default, daemon is unowned to start.
o := s.ActiveProfileOwnership()
assert.Empty(t, o.Owners)
assert.False(t, o.Shared)
// Trust-on-first-use: the first caller claims daemon ownership, persisted.
claimed, err := s.ClaimActiveProfileOwnerIfUnowned(ipcauth.Identity{UID: 1000})
require.NoError(t, err)
assert.True(t, claimed)
assert.Equal(t, []string{"uid:1000"}, store.owners)
assert.Equal(t, []string{"uid:1000"}, s.ActiveProfileOwnership().Owners)
// A second, different caller does not re-claim an owned daemon.
claimed, err = s.ClaimActiveProfileOwnerIfUnowned(ipcauth.Identity{UID: 1001})
require.NoError(t, err)
assert.False(t, claimed)
// AddOwner appends a daemon-wide principal (persisted). The caller must be a
// daemon owner: uid:1000 claimed ownership above.
_, err = s.AddOwner(ctxWithIdentity(ipcauth.Identity{UID: 1000}), &proto.AddOwnerRequest{Principal: "uid:1001"})
require.NoError(t, err)
assert.Equal(t, []string{"uid:1000", "uid:1001"}, store.owners)
// ResetOwner (privileged) clears the daemon owner set.
_, err = s.ResetOwner(ctxWithIdentity(ipcauth.Identity{UID: 0}), &proto.ResetOwnerRequest{})
require.NoError(t, err)
assert.Empty(t, store.owners)
assert.False(t, store.shared)
}
// TestOwnerMutationsRequireDaemonOwner checks the handler defense-in-depth: a
// caller who is neither a daemon owner nor privileged is denied at the handler.
func TestOwnerMutationsRequireDaemonOwner(t *testing.T) {
store := &fakeOwnerStore{owners: []string{"uid:1000"}}
s := &Server{groupResolver: ipcauth.NewDefaultGroupResolver()}
s.SetDaemonOwnerStore(store) // loads {uid:1000} into s.owners
owner := ctxWithIdentity(ipcauth.Identity{UID: 1000})
nonOwner := ctxWithIdentity(ipcauth.Identity{UID: 2000})
root := ctxWithIdentity(ipcauth.Identity{UID: 0})
t.Run("AddOwner denied for non-daemon-owner", func(t *testing.T) {
_, err := s.AddOwner(nonOwner, &proto.AddOwnerRequest{Principal: "uid:2000"})
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
assert.Equal(t, []string{"uid:1000"}, store.owners, "owner set must be unchanged")
})
t.Run("ShareProfile denied for non-daemon-owner", func(t *testing.T) {
_, err := s.ShareProfile(nonOwner, &proto.ShareProfileRequest{Shared: true})
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
assert.False(t, store.shared, "shared flag must be unchanged")
})
t.Run("AddOwner allowed for daemon owner", func(t *testing.T) {
_, err := s.AddOwner(owner, &proto.AddOwnerRequest{Principal: "uid:2000"})
require.NoError(t, err)
assert.Equal(t, []string{"uid:1000", "uid:2000"}, store.owners)
})
t.Run("ShareProfile allowed for root", func(t *testing.T) {
_, err := s.ShareProfile(root, &proto.ShareProfileRequest{Shared: true})
require.NoError(t, err)
assert.True(t, store.shared)
})
}
// TestDaemonOwnerAllOwnersUseDefault verifies every daemon owner is authorized
// for the default profile, while a non-owner is denied.
func TestDaemonOwnerAllOwnersUseDefault(t *testing.T) {
s := &Server{groupResolver: ipcauth.NewDefaultGroupResolver()}
s.owners = ipcauth.Ownership{Owners: []string{"uid:1000", "uid:1001"}}
deflt := &profilemanager.Profile{ID: profilemanager.ID(profilemanager.DefaultProfileName), Name: "default"}
// Both owners may use the default profile.
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(ipcauth.Identity{UID: 1000}), deflt, true))
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(ipcauth.Identity{UID: 1001}), deflt, true))
// A non-owner is denied the default profile.
err := s.authorizeTargetProfile(ctxWithIdentity(ipcauth.Identity{UID: 2000}), deflt, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
}
// writeTargetProfile writes a profile JSON with the given ownership and returns
// a Profile handle pointing at it (Path set, so FilePath() resolves directly).
func writeTargetProfile(t *testing.T, dir, id string, owners []string, shared bool) *profilemanager.Profile {
t.Helper()
path := filepath.Join(dir, id+".json")
cfg := &profilemanager.Config{Owners: owners, Shared: shared}
require.NoError(t, util.WriteJson(context.Background(), path, cfg))
return &profilemanager.Profile{ID: profilemanager.ID(id), Name: id, Path: path}
}
func readOwners(t *testing.T, path string) ([]string, bool) {
t.Helper()
cfg, err := profilemanager.GetConfig(path)
require.NoError(t, err)
return cfg.Owners, cfg.Shared
}
func TestAuthorizeTargetProfile(t *testing.T) {
s := &Server{groupResolver: ipcauth.NewDefaultGroupResolver()}
owner := ipcauth.Identity{UID: 1000}
other := ipcauth.Identity{UID: 1001}
root := ipcauth.Identity{UID: 0}
t.Run("no identity denies", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
err := s.authorizeTargetProfile(context.Background(), p, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
})
t.Run("privileged allowed on another's profile", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(root), p, true))
})
t.Run("owner allowed", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(owner), p, true))
})
t.Run("non-owner denied", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", []string{"uid:1000"}, false)
err := s.authorizeTargetProfile(ctxWithIdentity(other), p, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
})
t.Run("shared allows any caller", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", nil, true)
assert.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, true))
})
t.Run("unowned claim stamps owner", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", nil, false)
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, true))
owners, shared := readOwners(t, p.Path)
assert.Equal(t, []string{"uid:1001"}, owners)
assert.False(t, shared)
// A different caller is now locked out of the claimed profile.
err := s.authorizeTargetProfile(ctxWithIdentity(owner), p, true)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
})
t.Run("unowned without claim leaves profile unowned", func(t *testing.T) {
p := writeTargetProfile(t, t.TempDir(), "p", nil, false)
require.NoError(t, s.authorizeTargetProfile(ctxWithIdentity(other), p, false))
owners, _ := readOwners(t, p.Path)
assert.Empty(t, owners)
})
}

View File

@@ -0,0 +1,51 @@
package server
import (
"context"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// bindCallerUsername enforces that a non-privileged caller may only operate on
// its OWN user's profiles. This binds the client-supplied gRPC field to the
// caller's kernel identity.
// Privileged callers (root / elevated-admin) may manage any user's profiles.
func (s *Server) bindCallerUsername(ctx context.Context, requested string) error {
if requested == "" {
return nil
}
id, ok := ipcauth.IdentityFromContext(ctx)
if !ok {
return gstatus.Error(codes.PermissionDenied, "caller identity could not be verified")
}
if id.IsPrivileged() {
return nil
}
caller, err := usernameForIdentity(id)
if err != nil {
log.Warnf("profile authz: resolve caller username for %s: %v", id, err)
return gstatus.Error(codes.PermissionDenied, "could not resolve caller identity to a username")
}
if !usernamesEqual(requested, caller) {
return gstatus.Errorf(codes.PermissionDenied,
"not authorized to operate on another user's profiles (caller %q requested %q)", caller, requested)
}
return nil
}
// usernamesEqual compares usernames case-insensitively on Windows (domain
// accounts) and exactly on Unix.
func usernamesEqual(a, b string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(a, b)
}
return a == b
}

View File

@@ -0,0 +1,20 @@
//go:build !windows
package server
import (
"strconv"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/shell"
)
// usernameForIdentity resolves a Unix caller's UID to its username via NSS
// (getent), so LDAP/AD users resolve correctly under CGO_ENABLED=0.
func usernameForIdentity(id ipcauth.Identity) (string, error) {
u, err := shell.GetUserFromGetent(strconv.FormatUint(uint64(id.UID), 10))
if err != nil {
return "", err
}
return u.Username, nil
}

View File

@@ -0,0 +1,18 @@
//go:build windows
package server
import (
"os/user"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// usernameForIdentity resolves a Windows caller's SID to its account name.
func usernameForIdentity(id ipcauth.Identity) (string, error) {
u, err := user.LookupId(id.SID)
if err != nil {
return "", err
}
return u.Username, nil
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
"github.com/netbirdio/netbird/client/mdm"
@@ -126,6 +127,21 @@ type Server struct {
updateManager *updater.Manager
jwtCache *jwtCache
// groupResolver resolves a Unix caller's supplementary group membership
// (NSS/getent) so gid:/group: owner principals authorize correctly. Nil on
// Windows, where SID group membership travels in the identity itself. ipcauth
// treats a nil resolver as "no group matching".
groupResolver ipcauth.GroupResolver
// owners is the in-memory daemon-wide owner set, loaded from daemonOwnerStore.
// It governs the default profile and daemon-wide access. Non-default profiles
// stay isolated per user via their own per-profile owner. Guarded by s.mutex.
owners ipcauth.Ownership
// daemonOwnerStore persists owners to service.json, injected by cmd via
// SetDaemonOwnerStore. Nil before injection or in tests, where the daemon is
// treated as unowned and non-privileged callers are denied on the default.
daemonOwnerStore DaemonOwnerStore
}
type oauthAuthFlow struct {
@@ -150,6 +166,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
jwtCache: newJWTCache(),
extendAuthSessionFlow: auth.NewPendingFlow(),
probeThrottle: newProbeThrottle(probeThreshold),
groupResolver: ipcauth.NewDefaultGroupResolver(),
}
agent := &serverAgent{s}
s.sleepHandler = sleephandler.New(agent)
@@ -402,6 +419,11 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
}
}
// SSH root login / disabling SSH auth requires a privileged caller.
if err := requirePrivilegedForDangerousSSH(callerCtx, msg.EnableSSHRoot, msg.DisableSSHAuth); err != nil {
return nil, err
}
// MDM gate: refuse the whole request if any of its fields is enforced
// by the active MDM policy. The error carries an MDMManagedFields-
// Violation detail listing the offending key names. Non-conflicting
@@ -537,6 +559,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
}
// SSH root login / disabling SSH auth requires a privileged caller.
if err := requirePrivilegedForDangerousSSH(callerCtx, msg.EnableSSHRoot, msg.DisableSSHAuth); err != nil {
return nil, err
}
s.mutex.Lock()
if s.actCancel != nil {
s.actCancel()
@@ -943,6 +970,17 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
}
s.config = config
// --owner: explicitly claim ownership of the active profile for the caller.
if msg != nil && msg.GetClaimOwner() {
if id, ok := ipcauth.IdentityFromContext(callerCtx); ok {
if err := s.claimForCallerLocked(id, s.config); err != nil {
s.mutex.Unlock()
log.Errorf("failed to claim profile ownership: %v", err)
return nil, fmt.Errorf("claim owner: %w", err)
}
}
}
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
@@ -1048,6 +1086,29 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
}
if msg != nil && msg.ProfileName != nil {
targetUsername := ""
if msg.Username != nil {
targetUsername = *msg.Username
}
// The interceptor already gated this against the CURRENT active profile;
// also bind the TARGET profile's username so a caller can't switch into
// another user's profile.
if err := s.bindCallerUsername(callerCtx, targetUsername); err != nil {
return nil, err
}
// Authorize against the target profile's owners, claiming an unowned
// legacy target for the caller.
resolveUsername := targetUsername
if *msg.ProfileName == profilemanager.DefaultProfileName {
resolveUsername = ""
}
resolvedTarget, err := s.resolveProfileHandle(*msg.ProfileName, resolveUsername)
if err != nil {
return nil, err
}
if err := s.authorizeTargetProfile(callerCtx, resolvedTarget, true); err != nil {
return nil, err
}
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
log.Errorf("failed to switch profile: %v", err)
return nil, err
@@ -1081,7 +1142,10 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
if err := s.cleanupConnection(); err != nil {
s.mutex.Unlock()
// todo review to update the status in case any type of error
if errors.Is(err, ErrServiceNotUp) {
log.Debugf("Down called while service not up: %v", err)
return nil, err
}
log.Errorf("failed to shut down properly: %v", err)
return nil, err
}
@@ -1154,7 +1218,7 @@ func (s *Server) cleanupConnection() error {
// making the run loop the sole owner of engine shutdown.
if engine != nil {
if err := engine.Stop(); err != nil {
return err
log.Errorf("failed to stop engine during cleanup: %v", err)
}
}
@@ -2002,7 +2066,18 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided")
}
created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username)
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
return nil, err
}
// Auto-isolate the new profile to its creator. When root/admin creates it we
// leave it unowned so the intended user claims it via trust-on-first-use.
var initialOwners []string
if id, ok := ipcauth.IdentityFromContext(ctx); ok && !id.IsPrivileged() {
initialOwners = []string{ipcauth.OwnerPrincipalForIdentity(id)}
}
created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username, initialOwners)
if err != nil {
log.Errorf("failed to create profile: %v", err)
return nil, fmt.Errorf("failed to create profile: %w", err)
@@ -2025,11 +2100,19 @@ func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequ
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided")
}
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
return nil, err
}
resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username)
if err != nil {
return nil, err
}
if err := s.authorizeTargetProfile(ctx, resolved, true); err != nil {
return nil, err
}
err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName)
if err != nil {
log.Errorf("failed to rename profile: %v", err)
@@ -2054,11 +2137,20 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
}
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
return nil, err
}
resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
if err != nil {
return nil, err
}
// claim=false: don't stamp ownership on a profile we're about to delete.
if err := s.authorizeTargetProfile(ctx, resolved, false); err != nil {
return nil, err
}
if err := s.logoutFromProfile(ctx, resolved); err != nil {
log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err)
}
@@ -2122,6 +2214,10 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided")
}
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
return nil, err
}
profiles, err := s.profileManager.ListProfiles(msg.Username)
if err != nil {
log.Errorf("failed to list profiles: %v", err)

36
client/server/ssh_gate.go Normal file
View File

@@ -0,0 +1,36 @@
package server
import (
"context"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// requirePrivilegedForDangerousSSH enforces admin permissions for SSH config.
// Enabling SSH root login or disabling SSH authentication turns the
// root/LocalSystem daemon's SSH server into an unauthenticated root shell
// (local-to-remote-root escalation), so only a privileged caller may set
// these flags to true over the local IPC.
func requirePrivilegedForDangerousSSH(ctx context.Context, enableSSHRoot, disableSSHAuth *bool) error {
dangerous := (enableSSHRoot != nil && *enableSSHRoot) || (disableSSHAuth != nil && *disableSSHAuth)
if !dangerous {
return nil
}
id, ok := ipcauth.IdentityFromContext(ctx)
if !ok {
log.Warnf("denying SSH root/no-auth config change: caller identity unavailable on this control channel")
return gstatus.Error(codes.PermissionDenied,
"enabling SSH root login or disabling SSH authentication requires root/administrator, but the caller identity could not be verified on this daemon control channel")
}
if !id.IsPrivileged() {
log.Warnf("denying SSH root/no-auth config change from non-privileged caller %s", id)
return gstatus.Errorf(codes.PermissionDenied,
"enabling SSH root login or disabling SSH authentication requires root/administrator (caller %s is not privileged); rerun as root/administrator", id)
}
return nil
}

View File

@@ -0,0 +1,52 @@
package server
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/peer"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
func ctxWithIdentity(id ipcauth.Identity) context.Context {
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: ipcauth.AuthInfo{Identity: id}})
}
func boolPtr(b bool) *bool { return &b }
func TestRequirePrivilegedForDangerousSSH(t *testing.T) {
root := ipcauth.Identity{UID: 0}
user := ipcauth.Identity{UID: 1000}
tests := []struct {
name string
ctx context.Context
enableSSHRoot *bool
disableSSHAuth *bool
wantDenied bool
}{
{"no flags, no identity", context.Background(), nil, nil, false},
{"flags false, non-priv", ctxWithIdentity(user), boolPtr(false), boolPtr(false), false},
{"enableSSHRoot by root", ctxWithIdentity(root), boolPtr(true), nil, false},
{"enableSSHRoot by non-priv", ctxWithIdentity(user), boolPtr(true), nil, true},
{"disableSSHAuth by non-priv", ctxWithIdentity(user), nil, boolPtr(true), true},
{"enableSSHRoot no identity (fail closed)", context.Background(), boolPtr(true), nil, true},
{"both by root", ctxWithIdentity(root), boolPtr(true), boolPtr(true), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := requirePrivilegedForDangerousSSH(tt.ctx, tt.enableSSHRoot, tt.disableSSHAuth)
if tt.wantDenied {
assert.Error(t, err)
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
} else {
assert.NoError(t, err)
}
})
}
}

View File

@@ -20,6 +20,8 @@ import (
"github.com/creack/pty"
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
shellutil "github.com/netbirdio/netbird/client/internal/shell"
)
// ptyManager manages Pty file operations with thread safety
@@ -146,10 +148,10 @@ func (s *Server) createShellCommand(ctx context.Context, shell string, args []st
// prepareCommandEnv prepares environment variables for command execution on Unix
func (s *Server) prepareCommandEnv(_ *log.Entry, localUser *user.User, session ssh.Session) []string {
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
env = append(env, prepareSSHEnv(session)...)
for _, v := range session.Environ() {
if acceptEnv(v) {
if shellutil.AcceptEnv(v) {
env = append(env, v)
}
}

View File

@@ -15,6 +15,7 @@ import (
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
shellutil "github.com/netbirdio/netbird/client/internal/shell"
"github.com/netbirdio/netbird/client/ssh/server/winpty"
)
@@ -247,10 +248,10 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess
userEnv, err := s.getUserEnvironment(logger, username, domain)
if err != nil {
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
env = append(env, prepareSSHEnv(session)...)
for _, v := range session.Environ() {
if acceptEnv(v) {
if shellutil.AcceptEnv(v) {
env = append(env, v)
}
}
@@ -260,7 +261,7 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess
env := userEnv
env = append(env, prepareSSHEnv(session)...)
for _, v := range session.Environ() {
if acceptEnv(v) {
if shellutil.AcceptEnv(v) {
env = append(env, v)
}
}
@@ -273,7 +274,7 @@ func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privileg
return false
}
shell := getUserShell(privilegeResult.User.Uid)
shell := shellutil.GetUserShell(privilegeResult.User.Uid)
logger.Infof("starting interactive shell: %s", shell)
s.executeCommandWithPty(logger, session, nil, privilegeResult, ptyReq, nil)
@@ -384,7 +385,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
}
username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
shell := shellutil.GetUserShell(localUser.Uid)
req := PtyExecutionRequest{
Shell: shell,

View File

@@ -1,24 +0,0 @@
//go:build cgo && !osusergo && !windows
package server
import "os/user"
// lookupWithGetent with CGO delegates directly to os/user.Lookup.
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through
// the NSS stack natively. If it fails, the user truly doesn't exist and
// getent would also fail.
func lookupWithGetent(username string) (*user.User, error) {
return user.Lookup(username)
}
// currentUserWithGetent with CGO delegates directly to os/user.Current.
func currentUserWithGetent() (*user.User, error) {
return user.Current()
}
// groupIdsWithFallback with CGO delegates directly to user.GroupIds.
// libc's getgrouplist handles NSS groups natively.
func groupIdsWithFallback(u *user.User) ([]string, error) {
return u.GroupIds()
}

View File

@@ -1,26 +0,0 @@
//go:build windows
package server
import "os/user"
// lookupWithGetent on Windows just delegates to os/user.Lookup.
// Windows does not use NSS/getent; its user lookup works without CGO.
func lookupWithGetent(username string) (*user.User, error) {
return user.Lookup(username)
}
// currentUserWithGetent on Windows just delegates to os/user.Current.
func currentUserWithGetent() (*user.User, error) {
return user.Current()
}
// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection.
func getShellFromGetent(_ string) string {
return ""
}
// groupIdsWithFallback on Windows just delegates to u.GroupIds().
func groupIdsWithFallback(u *user.User) ([]string, error) {
return u.GroupIds()
}

View File

@@ -0,0 +1,35 @@
package server
import (
"fmt"
"net"
"strconv"
"github.com/gliderlabs/ssh"
)
// prepareSSHEnv prepares SSH protocol-specific environment variables
// These variables provide information about the SSH connection itself
func prepareSSHEnv(session ssh.Session) []string {
remoteAddr := session.RemoteAddr()
localAddr := session.LocalAddr()
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String())
if err != nil {
remoteHost = remoteAddr.String()
remotePort = "0"
}
localHost, localPort, err := net.SplitHostPort(localAddr.String())
if err != nil {
localHost = localAddr.String()
localPort = strconv.Itoa(InternalSSHPort)
}
return []string{
// SSH_CLIENT format: "client_ip client_port server_port"
fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort),
// SSH_CONNECTION format: "client_ip client_port server_ip server_port"
fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort),
}
}

View File

@@ -9,6 +9,8 @@ import (
"strings"
log "github.com/sirupsen/logrus"
shellutil "github.com/netbirdio/netbird/client/internal/shell"
)
var (
@@ -23,8 +25,8 @@ func isPlatformUnix() bool {
// Dependency injection variables for testing - allows mocking dynamic runtime checks
var (
getCurrentUser = currentUserWithGetent
lookupUser = lookupWithGetent
getCurrentUser = shellutil.CurrentUserWithGetent
lookupUser = shellutil.LookupWithGetent
getCurrentOS = func() string { return runtime.GOOS }
getIsProcessPrivileged = isCurrentProcessPrivileged

View File

@@ -16,6 +16,8 @@ import (
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
shellutil "github.com/netbirdio/netbird/client/internal/shell"
)
// POSIX portable filename character set regex: [a-zA-Z0-9._-]
@@ -160,7 +162,7 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u
// getSupplementaryGroups retrieves supplementary group IDs for a user.
// Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds.
func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) {
groupIDStrings, err := groupIdsWithFallback(u)
groupIDStrings, err := shellutil.GroupIdsWithFallback(u)
if err != nil {
return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err)
}
@@ -196,7 +198,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
GID: gid,
Groups: groups,
WorkingDir: localUser.HomeDir,
Shell: getUserShell(localUser.Uid),
Shell: shellutil.GetUserShell(localUser.Uid),
Command: session.RawCommand(),
PTY: hasPty,
}
@@ -228,7 +230,7 @@ func (s *Server) createPtyCommand(privilegeResult PrivilegeCheckResult, ptyReq s
func (s *Server) createDirectPtyCommand(session ssh.Session, localUser *user.User, ptyReq ssh.Pty) *exec.Cmd {
log.Debugf("creating direct Pty command for user %s (no user switching needed)", localUser.Username)
shell := getUserShell(localUser.Uid)
shell := shellutil.GetUserShell(localUser.Uid)
args := s.getShellCommandArgs(shell, session.RawCommand())
cmd := s.createShellCommand(session.Context(), shell, args)
@@ -245,12 +247,12 @@ func (s *Server) preparePtyEnv(localUser *user.User, ptyReq ssh.Pty, session ssh
termType = "xterm-256color"
}
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
env = append(env, prepareSSHEnv(session)...)
env = append(env, fmt.Sprintf("TERM=%s", termType))
for _, v := range session.Environ() {
if acceptEnv(v) {
if shellutil.AcceptEnv(v) {
env = append(env, v)
}
}

View File

@@ -13,6 +13,8 @@ import (
"github.com/gliderlabs/ssh"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
shellutil "github.com/netbirdio/netbird/client/internal/shell"
)
// validateUsername validates Windows usernames according to SAM Account Name rules
@@ -104,7 +106,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
shell := shellutil.GetUserShell(localUser.Uid)
rawCmd := session.RawCommand()
var command string

View File

@@ -6,3 +6,4 @@ frontend/bindings
frontend/.vite
build/linux/appimage/build
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
build/windows/frontend

View File

@@ -51,7 +51,7 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool {
// netbirdFootprintExists reports whether the machine already carries NetBird
// daemon config or state, meaning this is not a genuinely fresh install. It is
// the update-safety gate for the autostart default: upgrading users always
// have a footprint, so an update can never trigger a login-item write.
// have a footprint, so an update can never trigger a autostart entry write.
func netbirdFootprintExists() bool {
candidates := []string{
profilemanager.DefaultConfigPath,
@@ -69,9 +69,23 @@ func netbirdFootprintExists() bool {
// applyAutostartDefault runs the one-time launch-on-login default for genuinely
// fresh installs. The autostartInitialized marker is persisted before any
// enable attempt so a crash mid-flow degrades to "never enabled" instead of
// retrying login-item writes on every launch. A user's later disable in
// retrying autostart entry writes on every launch. A user's later disable in
// Settings is never overridden: the marker guarantees at-most-once, ever.
func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy())
if mdmDisabled {
if enabled, err := autostart.IsEnabled(ctx); err != nil {
log.Warnf("MDM disableAutostart: read autostart state: %v", err)
} else if enabled {
if err := autostart.SetEnabled(ctx, false); err != nil {
log.Warnf("MDM disableAutostart: force off failed: %v", err)
} else {
log.Info("MDM disableAutostart enforced: autostart turned off")
}
}
}
priorFootprint := netbirdFootprintExists() || prefsFileExisted
if prefs.Get().AutostartInitialized {
@@ -84,7 +98,7 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p
state := autostartDefaultState{
supported: autostart.Supported(ctx),
mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()),
mdmDisabled: mdmDisabled,
priorInstall: priorFootprint,
}
enable, reason := shouldEnableAutostartDefault(state)

View File

@@ -1,10 +1,13 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Version } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
import { useStatus } from "@/contexts/StatusContext.tsx";
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc";
function openUrl(url: string) {
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
@@ -12,7 +15,26 @@ function openUrl(url: string) {
export const DaemonOutdatedOverlay = () => {
const { t } = useTranslation();
const { isDaemonOutdated } = useStatus();
const { status, isDaemonOutdated } = useStatus();
const [guiVersion, setGuiVersion] = useState<string>("-");
const clientVersion = status?.daemonVersion ?? "—";
const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion);
const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL;
useEffect(() => {
if (!isDaemonOutdated) return;
let cancelled = false;
Version.GUI()
.then((v) => {
if (!cancelled) setGuiVersion(v);
})
.catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err));
return () => {
cancelled = true;
};
}, [isDaemonOutdated]);
if (!isDaemonOutdated) return null;
@@ -38,10 +60,37 @@ export const DaemonOutdatedOverlay = () => {
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
</div>
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<p className={"text-sm font-semibold text-nb-gray-100"}>
{clientVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.client", { version: clientVersion })
)}
</p>
<p className={"text-sm font-medium text-nb-gray-250"}>
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.gui", { version: guiVersion })
)}
</p>
</div>
<div className={"wails-no-draggable"}>
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(downloadUrl)}>
<DownloadIcon size={14} />
{t("update.card.getInstaller")}
{t("daemon.outdated.download")}
</Button>
</div>
</div>

View File

@@ -28,6 +28,7 @@ type ProfileContextValue = {
loaded: boolean;
refresh: () => Promise<void>;
switchProfile: (id: string) => Promise<void>;
switchProfileNoConnect: (id: string) => Promise<void>;
addProfile: (name: string) => Promise<string>;
removeProfile: (id: string) => Promise<void>;
renameProfile: (id: string, newName: string) => Promise<void>;
@@ -112,6 +113,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
[username, refresh],
);
// Manage-profiles variant: switches without connecting, so the user can
// still adjust the management URL before bringing the connection up.
const switchProfileNoConnect = useCallback(
async (id: string) => {
await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username });
await refresh();
},
[username, refresh],
);
// addProfile creates a profile by display name and returns the
// daemon-generated ID, so the caller can immediately address it by ID.
const addProfile = useCallback(
@@ -158,6 +169,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
loaded,
refresh,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,
@@ -171,6 +183,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
loaded,
refresh,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,

View File

@@ -45,7 +45,7 @@ export function ProfilesTab() {
activeProfileId,
loaded,
username,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,
@@ -100,7 +100,7 @@ export function ProfilesTab() {
confirmLabel: t("profile.switch.confirm"),
});
if (!ok) return;
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id));
};
const handleDeregister = async (id: string, name: string) => {
@@ -129,14 +129,13 @@ export function ProfilesTab() {
await guarded(i18next.t("profile.error.createTitle"), async () => {
const id = await addProfile(name);
// SetConfig is keyed by the new profile's ID, so it writes the
// not-yet-active profile. Write before switching so any reconnect
// targets the right deployment.
// not-yet-active profile before the switch makes it current.
if (!isNetbirdCloud(managementUrl)) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: id, username, managementUrl }),
);
}
await switchProfile(id);
await switchProfileNoConnect(id);
});
};

View File

@@ -73,6 +73,13 @@ export default function SessionExpirationDialog() {
let offCancel: (() => void) | undefined;
// Return the dialog to its interactive state and dismiss the browser popup
const resetDialog = () => {
offCancel?.();
WindowManager.CloseBrowserLogin().catch(console.error);
setBusy(false);
};
try {
const start = await Session.RequestExtend({ hint: "" });
const uri = start.verificationUriComplete || start.verificationUri;
@@ -105,25 +112,22 @@ export default function SessionExpirationDialog() {
if (outcome.kind === "cancel") {
waitPromise.cancel?.();
waitPromise.catch(() => {});
resetDialog();
return;
}
// Another surface owns this flow; keep the dialog open to retry.
if (outcome.result.preempted) {
resetDialog();
return;
}
// Close before the popup so the restore can't flash this window back.
WindowManager.CloseSessionExpiration().catch(console.error);
WindowManager.CloseRenewFlow().catch(console.error);
} catch (e) {
resetDialog();
await errorDialog({
Title: t("sessionExpiration.extendFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
offCancel?.();
WindowManager.CloseBrowserLogin().catch(console.error);
setBusy(false);
}
}, [busy, t]);
@@ -139,12 +143,11 @@ export default function SessionExpirationDialog() {
});
WindowManager.CloseSessionExpiration().catch(console.error);
} catch (e) {
setBusy(false);
await errorDialog({
Title: t("sessionExpiration.logoutFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
}, [busy, t]);

View File

@@ -22,6 +22,9 @@ type WelcomeStepTrayProps = {
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
const { t } = useTranslation();
const trayScreenshot = trayScreenshotForOS();
// macOS has no tray — the icon sits in the menu bar, so the copy says so.
const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title";
const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description";
return (
<>
@@ -36,9 +39,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
<div className={"flex w-full flex-col gap-1"}>
<DialogHeading id={"nb-welcome-title"} align={"left"}>
{t("welcome.title")}
{t(titleKey)}
</DialogHeading>
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
</div>
<DialogActions>

View File

@@ -3,16 +3,16 @@
package main
import (
"context"
"fmt"
"runtime"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/backoff"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/cmd"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/desktop"
)
@@ -36,9 +36,7 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
return c.client, nil
}
cc, err := grpc.NewClient(
strings.TrimPrefix(c.addr, "tcp://"),
grpc.WithTransportCredentials(insecure.NewCredentials()),
opts := []grpc.DialOption{
grpc.WithUserAgent(desktop.GetUIUserAgent()),
// Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would
// leave the UI waiting 30-60s to notice a freshly-started daemon.
@@ -50,6 +48,12 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
MaxDelay: 5 * time.Second,
},
}),
}
cc, err := cmd.DialClientGRPCServer(
context.Background(),
c.addr,
opts...,
)
if err != nil {
return nil, fmt.Errorf("dial daemon: %w", err)
@@ -61,7 +65,7 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
// DaemonAddr returns the default daemon gRPC address: a Unix socket on Linux/macOS, TCP loopback on Windows.
func DaemonAddr() string {
if runtime.GOOS == "windows" {
return "tcp://127.0.0.1:41731"
return "npipe://netbird"
}
return "unix:///var/run/netbird.sock"
}

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Suchen Sie NetBird in der Taskleiste"
},
"welcome.titleMac": {
"message": "Suchen Sie NetBird in der Menüleiste"
},
"welcome.description": {
"message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
},
"welcome.descriptionMac": {
"message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
},
"welcome.continue": {
"message": "Weiter"
},
@@ -1293,10 +1299,13 @@
"message": "Dokumentation"
},
"daemon.outdated.title": {
"message": "NetBird-Dienst ist veraltet"
"message": "NetBird Client ist veraltet"
},
"daemon.outdated.description": {
"message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden."
"message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden."
},
"daemon.outdated.download": {
"message": "Neueste Version herunterladen"
},
"error.jwt_clock_skew": {
"message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut."

View File

@@ -1377,11 +1377,19 @@
},
"welcome.title": {
"message": "Look for NetBird in your tray",
"description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar."
"description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac."
},
"welcome.titleMac": {
"message": "Look for NetBird in your menu bar",
"description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar."
},
"welcome.description": {
"message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
"description": "Body of the first onboarding step explaining the tray icon."
"description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac."
},
"welcome.descriptionMac": {
"message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.",
"description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar."
},
"welcome.continue": {
"message": "Continue",
@@ -1724,12 +1732,16 @@
"description": "Documentation link on the daemon-unavailable overlay."
},
"daemon.outdated.title": {
"message": "NetBird Service Is Outdated",
"description": "Title of the overlay shown when the NetBird background service is too old to drive this UI."
"message": "NetBird Client Is Outdated",
"description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI."
},
"daemon.outdated.description": {
"message": "Update the NetBird service to use this app.",
"description": "Body of the daemon-outdated overlay telling the user to upgrade the service."
"message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.",
"description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated."
},
"daemon.outdated.download": {
"message": "Download Latest",
"description": "Button on the daemon-outdated overlay that opens the download page for the latest release."
},
"error.jwt_clock_skew": {
"message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Busque NetBird en su bandeja del sistema"
},
"welcome.titleMac": {
"message": "Busque NetBird en su barra de menús"
},
"welcome.description": {
"message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
},
"welcome.descriptionMac": {
"message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
},
"welcome.continue": {
"message": "Continuar"
},
@@ -1293,10 +1299,13 @@
"message": "Documentación"
},
"daemon.outdated.title": {
"message": "El servicio de NetBird está desactualizado"
"message": "NetBird Client está desactualizado"
},
"daemon.outdated.description": {
"message": "Actualice el servicio de NetBird para usar esta aplicación."
"message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación."
},
"daemon.outdated.download": {
"message": "Descargar la última versión"
},
"error.jwt_clock_skew": {
"message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Cherchez NetBird dans votre barre détat système"
},
"welcome.titleMac": {
"message": "Cherchez NetBird dans votre barre des menus"
},
"welcome.description": {
"message": "NetBird se trouve dans votre barre détat système. Cliquez sur licône pour vous connecter, changer de profil ou ouvrir les paramètres."
},
"welcome.descriptionMac": {
"message": "NetBird se trouve dans votre barre des menus. Cliquez sur licône pour vous connecter, changer de profil ou ouvrir les paramètres."
},
"welcome.continue": {
"message": "Continuer"
},
@@ -1293,10 +1299,13 @@
"message": "Documentation"
},
"daemon.outdated.title": {
"message": "Le service NetBird est obsolète"
"message": "Le Client NetBird est obsolète"
},
"daemon.outdated.description": {
"message": "Mettez à jour le service NetBird pour utiliser cette application."
"message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application."
},
"daemon.outdated.download": {
"message": "Télécharger la dernière version"
},
"error.jwt_clock_skew": {
"message": "Échec de la connexion : lhorloge de cet appareil nest pas synchronisée avec le serveur. Veuillez synchroniser lhorloge de votre système et réessayer."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Keresse a NetBirdöt a tálcán"
},
"welcome.titleMac": {
"message": "Keresse a NetBirdöt a menüsorban"
},
"welcome.description": {
"message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
},
"welcome.descriptionMac": {
"message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
},
"welcome.continue": {
"message": "Folytatás"
},
@@ -1293,10 +1299,13 @@
"message": "Dokumentáció"
},
"daemon.outdated.title": {
"message": "A NetBird szolgáltatás elavult"
"message": "A NetBird Kliens elavult"
},
"daemon.outdated.description": {
"message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához."
"message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához."
},
"daemon.outdated.download": {
"message": "Legújabb letöltése"
},
"error.jwt_clock_skew": {
"message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Cerchi NetBird nella tray"
},
"welcome.titleMac": {
"message": "Cerchi NetBird nella barra dei menu"
},
"welcome.description": {
"message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
},
"welcome.descriptionMac": {
"message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
},
"welcome.continue": {
"message": "Continua"
},
@@ -1293,10 +1299,13 @@
"message": "Documentazione"
},
"daemon.outdated.title": {
"message": "Il servizio NetBird è obsoleto"
"message": "NetBird Client è obsoleto"
},
"daemon.outdated.description": {
"message": "Aggiorna il servizio NetBird per usare questa app."
"message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione."
},
"daemon.outdated.download": {
"message": "Scarica l'ultima versione"
},
"error.jwt_clock_skew": {
"message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "トレイの NetBird を確認してください"
},
"welcome.titleMac": {
"message": "メニューバーの NetBird を確認してください"
},
"welcome.description": {
"message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
},
"welcome.descriptionMac": {
"message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
},
"welcome.continue": {
"message": "続ける"
},

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Procure o NetBird na sua bandeja"
},
"welcome.titleMac": {
"message": "Procure o NetBird na sua barra de menus"
},
"welcome.description": {
"message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
},
"welcome.descriptionMac": {
"message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
},
"welcome.continue": {
"message": "Continuar"
},
@@ -1293,10 +1299,13 @@
"message": "Documentação"
},
"daemon.outdated.title": {
"message": "O serviço NetBird está desatualizado"
"message": "O NetBird Client está desatualizado"
},
"daemon.outdated.description": {
"message": "Atualize o serviço NetBird para usar este aplicativo."
"message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo."
},
"daemon.outdated.download": {
"message": "Baixar a versão mais recente"
},
"error.jwt_clock_skew": {
"message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "Найдите NetBird в системном трее"
},
"welcome.titleMac": {
"message": "Найдите NetBird в строке меню"
},
"welcome.description": {
"message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
},
"welcome.descriptionMac": {
"message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
},
"welcome.continue": {
"message": "Продолжить"
},
@@ -1293,10 +1299,13 @@
"message": "Документация"
},
"daemon.outdated.title": {
"message": "Служба NetBird устарела"
"message": "Клиент NetBird устарел"
},
"daemon.outdated.description": {
"message": "Обновите службу NetBird, чтобы использовать это приложение."
"message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение."
},
"daemon.outdated.download": {
"message": "Скачать последнюю версию"
},
"error.jwt_clock_skew": {
"message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку."

View File

@@ -1034,9 +1034,15 @@
"welcome.title": {
"message": "在托盘中查找 NetBird"
},
"welcome.titleMac": {
"message": "在菜单栏中查找 NetBird"
},
"welcome.description": {
"message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。"
},
"welcome.descriptionMac": {
"message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。"
},
"welcome.continue": {
"message": "继续"
},
@@ -1293,10 +1299,13 @@
"message": "文档"
},
"daemon.outdated.title": {
"message": "NetBird 服务版本过旧"
"message": "NetBird 客户端版本过旧"
},
"daemon.outdated.description": {
"message": "请更新 NetBird 服务以使用应用。"
"message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用应用。"
},
"daemon.outdated.download": {
"message": "下载最新版本"
},
"error.jwt_clock_skew": {
"message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。"

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