Compare commits

...

128 Commits

Author SHA1 Message Date
Viktor Liu
86d775fbc2 Merge main into embedded-vnc
# Conflicts:
#	client/configs/configs.go
2026-07-31 21:11:49 +02:00
Viktor Liu
a117c19b0d Treat an unreadable SoftwareSASGeneration as unknown rather than absent 2026-07-31 21:07:19 +02:00
Misha Bragin
0780a806f2 [management, proxy] Management-owned LLM pricing: file-backed defaults + (#6965) 2026-07-31 20:52:56 +02:00
Viktor Liu
59c75127a4 Move the service agent accessor to the platforms that have one 2026-07-31 19:23:26 +02:00
Viktor Liu
9c3347021a Restrict the SAS event to SYSTEM and restore its registry snapshot per cycle 2026-07-31 19:23:26 +02:00
Viktor Liu
feecb993f4 [client] Restrict debug bundle log path and upload destinations (#6975) 2026-07-31 19:12:56 +02:00
Maycon Santos
f51fadf8d4 [misc] update contributing guide (#7009)
## Describe your changes
We are updating the contributing guide to require an issue to be opened
before PRs; this will allow discussing changes before code is shipped.

Update the rest of the file because of outdated information and align
the pull request template
## Issue ticket number and link
2026-07-31 17:15:39 +02:00
Pascal Fischer
aad2702a14 [proxy] remove cluster tag from proxy metrics (#6985) 2026-07-31 16:56:41 +02:00
Ben
7516aa6473 [client] Fix expression order in legacy nftables route rules (#7011) 2026-07-31 16:28:42 +02:00
Viktor Liu
aed60a2432 [client] Fix daemon lock order inversion between SetConfig and login (#6978) 2026-07-31 15:53:58 +02:00
Maycon Santos
234abd7a08 [misc] group x package updates and run weekly (#7000) 2026-07-31 05:34:58 +02:00
Maycon Santos
c1f0006012 [misc] Update SECURITY.md (#6981)
## Describe your changes

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [ ] Is it a bug fix
- [x] 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

* **Documentation**
* Expanded the security vulnerability reporting policy with private
reporting options and guidance for hosted infrastructure issues.
* Added recommendations for report contents, acknowledgements, severity
assessment, remediation, advisories, and reporter credit.
* Clarified supported versions, advisory distribution, bug bounty
status, and where to report non-security issues.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 15:34:08 +02:00
Viktor Liu
90d2687e48 Push a status snapshot when VNC sessions start or end 2026-07-30 15:18:31 +02:00
Viktor Liu
abd5022e18 Flag active VNC sessions on the main screen 2026-07-30 15:18:31 +02:00
Viktor Liu
8e919b4ee9 Share one VNC session agent manager across all accept loops 2026-07-30 14:29:34 +02:00
Viktor Liu
cff49237b6 [client] Stop and remove the daemon on netbird-ui cask uninstall (#6977) 2026-07-30 13:11:27 +02:00
Viktor Liu
1836616608 Account for the VNC peer flag in the peer meta field-count guard 2026-07-30 12:28:32 +02:00
Viktor Liu
548cbfb1e0 Drop the VNC port constant left unused by the shared types move 2026-07-30 12:21:59 +02:00
Viktor Liu
0e2bd094bb Build VNC auth for peers on the component network map path 2026-07-30 11:01:35 +02:00
Viktor Liu
05a75d80eb Carry netbird-vnc policies over the component network map 2026-07-30 10:43:26 +02:00
Viktor Liu
abe8bc1b53 Require privilege for VNC server and approval config changes 2026-07-30 10:43:26 +02:00
Zoltan Papp
1bf54ddd8f [client] Support Andorid session expiry handling (#6945)
## Describe your changes

Adds the session surface the Android client was missing: read the status
label and session deadline, receive the engine's expiry warnings, extend
the session via SSO without dropping the tunnel (cancellable), and
dismiss a warning.

Status() latches NeedsLogin so an engine restart doesn't erase it.

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

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

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

* **New Features**
  * Added Android session status and session expiration details.
* Added listener support for wake-state changes and session-expiry
warnings.
* Added interactive authentication session extension with async
login-success handling, error reporting, warning dismissal, and
cancellation of an in-progress extension.
* **Bug Fixes**
* Improved “login required” state handling after successful sign-in to
prevent stale login-needed prompts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 10:27:52 +02:00
Viktor Liu
2b051ad250 Merge main into embedded-vnc 2026-07-30 09:58:02 +02:00
Viktor Liu
0f5d2d91fb [client] Authorize daemon IPC callers by their local identity (#6967) 2026-07-29 20:32:26 +02:00
Pascal Fischer
0b7e6a9f46 [signal] make pprof configurable (#6963) 2026-07-29 16:54:01 +02:00
Viktor Liu
152ba28d9f Add VNC allow and approval settings to MDM policy 2026-07-13 16:39:28 +02:00
Viktor Liu
eb6e8dc905 Reject empty approval request_id and verify gid after setgid 2026-07-13 11:52:50 +02:00
Viktor Liu
125250c5df Regenerate proto gateway and OpenAPI code with pinned tool versions 2026-07-13 00:06:33 +02:00
Viktor Liu
cec9ea8c00 Support IPv6 for the embedded VNC server and browser proxy 2026-07-12 18:46:47 +02:00
Viktor Liu
02e7c0e5d2 Port VNC settings and connection-approval prompt to the Wails UI 2026-07-12 18:46:41 +02:00
Viktor Liu
dbc7b846b2 Merge origin/main into embedded-vnc 2026-07-12 16:25:22 +02:00
Viktor Liu
fd7bf982c3 Split CreateTemporaryAccess into smaller functions 2026-06-28 17:41:50 +02:00
Viktor Liu
d568084d61 Merge remote-tracking branch 'origin/main' into embedded-vnc
# Conflicts:
#	client/proto/daemon.pb.go
#	client/ssh/proxy/proxy_test.go
#	client/wasm/cmd/main.go
#	shared/management/http/api/types.gen.go
2026-06-28 17:36:19 +02:00
Viktor Liu
c1eecaac26 Merge branch 'main' into embedded-vnc 2026-06-14 14:44:05 +02:00
Viktor Liu
f2c79201b3 Fix review findings for embedded VNC server 2026-06-10 10:57:50 +02:00
Viktor Liu
2fdc3aea4c Merge branch 'main' into embedded-vnc 2026-06-10 09:52:02 +02:00
Viktor Liu
144dfbc12c Capture listener locally in accept loops to avoid nil deref on Stop 2026-05-25 17:02:28 +02:00
Viktor Liu
6c9465df54 Handle Lstat error in purgeStaleAgentSubdir 2026-05-25 15:52:30 +02:00
Viktor Liu
6cd5d6084f Split prepareAgentSocketDir to reduce cognitive complexity 2026-05-25 15:09:28 +02:00
Viktor Liu
3bcacffd2c Rename xauth_linux.go to xauth_x11.go so FreeBSD picks it up 2026-05-25 14:10:01 +02:00
Viktor Liu
65f302b698 Authenticate virtual X11 sessions with per-session MIT-MAGIC-COOKIE-1 2026-05-25 13:26:29 +02:00
Viktor Liu
2f67841b1e Reuse /var/run/netbird as VNC agent socket parent via configs.RuntimeDir 2026-05-25 11:11:07 +02:00
Viktor Liu
bf2fb2fd44 Address CodeRabbit review on embedded VNC PR 2026-05-24 18:52:57 +02:00
Viktor Liu
4e3e3ce6d3 Surface VNC initiator in status, clarify proxy logs, dampen capture noise 2026-05-24 17:07:59 +02:00
Viktor Liu
5e2830be8a Harden VNC server, IPC, and management plumbing 2026-05-24 16:02:36 +02:00
Viktor Liu
f557e665a5 Return error from gateApproval and log at the caller 2026-05-23 19:50:27 +02:00
Viktor Liu
fa57eedaf5 Address CodeRabbit review and fix CI on embedded-vnc 2026-05-23 19:44:21 +02:00
Viktor Liu
7cb6388349 Decline VNC approval early when no console user is logged in 2026-05-23 19:15:01 +02:00
Viktor Liu
1f912be673 Address codespell and Sonar findings on embedded-vnc 2026-05-23 19:06:02 +02:00
Viktor Liu
8d329da591 Evict orphaned packet captures and annotate VNC streams 2026-05-23 18:33:55 +02:00
Viktor Liu
8e72967bbe Add per-connection user-approval prompts for VNC 2026-05-23 18:33:55 +02:00
Viktor Liu
c29ef638f4 Switch VNC daemon-to-agent IPC to Unix sockets and audit-log every connection 2026-05-22 15:32:35 +02:00
Viktor Liu
97b7b010f5 Fold init-only VNC and SSH setters into Config-struct constructors 2026-05-22 13:32:25 +02:00
Viktor Liu
030c57150f Signal Zlib encode failure and fall back to Raw 2026-05-22 12:06:52 +02:00
Viktor Liu
0f03c612d1 Lower CreateTemporaryAccess complexity and emit VncAuth for session pubkeys 2026-05-22 12:01:18 +02:00
Viktor Liu
1cc5967198 Address follow-up CodeRabbit VNC findings 2026-05-22 11:35:16 +02:00
Viktor Liu
412193c602 Address CodeRabbit VNC review feedback 2026-05-21 18:09:07 +02:00
Viktor Liu
5e67febf57 Address Sonar findings and move noise to direct dependency 2026-05-21 17:55:27 +02:00
Viktor Liu
ee348ba007 Abort VNC agent dial retry loop on server shutdown 2026-05-21 17:44:22 +02:00
Viktor Liu
3d3055dc7f Replace VNC JWT auth with a Noise_IK handshake bound to ACL-pushed pubkeys 2026-05-21 17:36:15 +02:00
Viktor Liu
2f4ddf0796 Emit explicit Fn flagsChanged transitions around macOS navigation keys 2026-05-21 12:30:14 +02:00
Viktor Liu
98d533c8e8 Address CodeRabbit feedback on VNC server agent matching and session lifecycle 2026-05-21 12:01:45 +02:00
Viktor Liu
ef4ea2e311 Set Fn flag on macOS navigation keycodes so the next key isn't treated as Fn-modified 2026-05-20 18:03:38 +02:00
Viktor Liu
b41d11bbbe Allow Cursor pseudo-encoding in session mode and cache last XFixes sprite 2026-05-20 17:39:07 +02:00
Viktor Liu
f37e228cc2 Replace magic env-var and subcommand strings with named constants 2026-05-20 17:22:02 +02:00
Viktor Liu
640a267556 Address CodeRabbit feedback on VNC server 2026-05-20 17:16:55 +02:00
Viktor Liu
17359cdc1e Fix VNC lint, 386 atomic alignment, and Sonar code smells 2026-05-20 16:34:29 +02:00
Viktor Liu
7e5846a1ee Resolve merge conflicts with main 2026-05-20 15:38:01 +02:00
Viktor Liu
517bea0daf Collapse X11 DISPLAY/XAUTHORITY auto-detect logs into one line 2026-05-20 15:36:26 +02:00
Viktor Liu
896530fd82 Add ExtendedMouseButtons for back/forward mouse buttons 2026-05-20 12:15:00 +02:00
Viktor Liu
354fd004c7 Enable IdP JWKS refresh in VNC JWT validator 2026-05-20 12:15:00 +02:00
Viktor Liu
c28e41e82b Track macOS click count and pixel-scale wheel scroll 2026-05-20 12:14:53 +02:00
Viktor Liu
02b9fe704b Use pixel-mode scroll on macOS for smoother wheel events 2026-05-20 12:14:45 +02:00
Viktor Liu
5e200fa571 Drop unreliable Sequoia preflight from macOS Screen Recording check 2026-05-20 12:14:37 +02:00
Viktor Liu
7d61975f6c Proxy macOS VNC connections from the LaunchDaemon to a per-user agent via launchctl asuser 2026-05-20 12:12:20 +02:00
Viktor Liu
62b36112ea Extract daemon-to-agent loopback proxy and token helpers into a platform-neutral file 2026-05-20 12:11:15 +02:00
Viktor Liu
df9a6fb020 Drop pbpaste trace log that fires whenever the macOS pasteboard is empty 2026-05-20 12:11:15 +02:00
Viktor Liu
b1b04f9ec6 Composite remote cursor into the framebuffer when the dashboard toggles it on 2026-05-20 12:11:15 +02:00
Viktor Liu
fe15688f20 Emit Cursor pseudo-encoding on Linux, Windows, and macOS 2026-05-20 12:11:15 +02:00
Viktor Liu
2285db2b62 Treat ExtendedClipboard messages with the Caps bit as Caps regardless of co-set action bits 2026-05-20 12:11:15 +02:00
Viktor Liu
b3f0f53a23 Collapse dirty rects to their bounding box when the bbox is densely dirty 2026-05-20 12:11:15 +02:00
Viktor Liu
5eec9962ba Honour client JPEG quality fully now that backpressure caps it dynamically 2026-05-20 12:11:15 +02:00
Viktor Liu
393c102f45 Throttle VNC encoder JPEG quality and skip frames under write backpressure 2026-05-20 12:11:15 +02:00
Viktor Liu
b41fbad5e1 Surface DXGI fallback to GDI at warn level on Windows 2026-05-20 12:11:15 +02:00
Viktor Liu
24a5f2252c Accept any RGB shift permutation as Tight-compatible per RFB 7.7.6 2026-05-20 12:11:15 +02:00
Viktor Liu
9d189bb3e8 Restore Hextile SolidFill and Zlib encoding paths 2026-05-20 12:11:15 +02:00
Maycon Santos
8e2505b59c [management] Add metrics for peer status updates and ephemeral cleanup (#6196)
* [management] Add metrics for peer status updates and ephemeral cleanup

The session-fenced MarkPeerConnected / MarkPeerDisconnected path and
the ephemeral peer cleanup loop both run silently today: when fencing
rejects a stale stream, when a cleanup tick deletes peers, or when a
batch delete fails, we have no operational signal beyond log lines.

Add OpenTelemetry counters and a histogram so the same SLO-style
dashboards that already exist for the network-map controller can cover
peer connect/disconnect and ephemeral cleanup too.

All new attributes are bounded enums: operation in {connect,disconnect}
and outcome in {applied,stale,error,peer_not_found}. No account, peer,
or user ID is ever written as a metric label — total cardinality is
fixed at compile time (8 counter series, 2 histogram series, 4 unlabeled
ephemeral series).

Metric methods are nil-receiver safe so test composition that doesn't
wire telemetry (the bulk of the existing tests) works unchanged. The
ephemeral manager exposes a SetMetrics setter rather than taking the
collector through its constructor, keeping the constructor signature
stable across all test call sites.

* [management] Add OpenTelemetry metrics for ephemeral peer cleanup

Introduce counters for tracking ephemeral peer cleanup, including peers pending deletion, cleanup runs, successful deletions, and failed batches. Metrics are nil-receiver safe to ensure compatibility with test setups without telemetry.
2026-05-20 12:11:15 +02:00
Maycon Santos
97bc1eebde [management] Fence peer status updates with a session token (#6193)
* [management] Fence peer status updates with a session token

The connect/disconnect path used a best-effort LastSeen-after-streamStart
comparison to decide whether a status update should land. Under contention
— a re-sync arriving while the previous stream's disconnect was still in
flight, or two management replicas seeing the same peer at once — the
check was a read-then-decide-then-write window: any UPDATE in between
caused the wrong row to be written. The Go-side time.Now() that fed the
comparison also drifted under lock contention, since it was captured
seconds before the write actually committed.

Replace it with an integer-nanosecond fencing token stored alongside the
status. Every gRPC sync stream uses its open time (UnixNano) as its token.
Connects only land when the incoming token is strictly greater than the
stored one; disconnects only land when the incoming token equals the
stored one (i.e. we're the stream that owns the current session). Both
are single optimistic-locked UPDATEs — no read-then-write, no transaction
wrapper.

LastSeen is now written by the database itself (CURRENT_TIMESTAMP). The
caller never supplies it, so the value always reflects the real moment
of the UPDATE rather than the moment the caller queued the work — which
was already off by minutes under heavy lock contention.

Side effects (geo lookup, peer-login-expiration scheduling, network-map
fan-out) are explicitly documented as running after the fence UPDATE
commits, never inside it. Geo also skips the update when realIP equals
the stored ConnectionIP, dropping a redundant SavePeerLocation call on
same-IP reconnects.

Tests cover the three semantic cases (matched disconnect lands, stale
disconnect dropped, stale connect dropped) plus a 16-goroutine race test
that asserts the highest token always wins.

* [management] Add SessionStartedAt to peer status updates

Stored `SessionStartedAt` for fencing token propagation across goroutines and updated database queries/functions to handle the new field. Removed outdated geolocation handling logic and adjusted tests for concurrency safety.

* Rename `peer_status_required_approval` to `peer_status_requires_approval` in SQL store fields
2026-05-20 12:11:15 +02:00
Nicolas Frati
32a5a061b8 [management] fix: device redirect uri wasn't registered (#6191)
* fix: device redirect uri wasn't registered

* fix lint
2026-05-20 12:11:15 +02:00
Viktor Liu
d927ef468a Clean up legacy 32-bit and HKCU registry entries on Windows install (#6176) 2026-05-20 12:11:15 +02:00
Maycon Santos
d3f3e08035 Avoid context cancellation in cancelPeerRoutines (#6175)
When closing go routines and handling peer disconnect, we should avoid canceling the flow due to parent gRPC context cancellation.

This change triggers disconnection handling with a context that is not bound to the parent gRPC cancellation.
2026-05-20 12:11:15 +02:00
Viktor Liu
6bb66e0fad [management] Avoid peer IP reallocation when account settings update preserves the network range (#6173) 2026-05-20 12:11:15 +02:00
Viktor Liu
bc407527f4 Register VNC netstack service only when netstack is active 2026-05-18 14:50:10 +02:00
Viktor Liu
5543404188 Cap honored VNC client JPEG quality at 50 2026-05-18 14:50:10 +02:00
Viktor Liu
c2fdf62f1f Detect dead VNC peers on both ends and report session stats 2026-05-18 14:50:10 +02:00
Viktor Liu
b9f5264e36 Restore createRDPProxy wasm entry point for dashboard RDP 2026-05-18 14:50:10 +02:00
Viktor Liu
97d0a6776f Release sticky modifiers and mouse buttons on client disconnect 2026-05-18 08:55:27 +02:00
Viktor Liu
7e7e056f3a Reset Tight zlib stream when deflater is recreated mid-session
Also scrub brand-name references from comments.
2026-05-18 07:54:21 +02:00
Viktor Liu
785f94d13f Guard buildExtClipProvideText against oversized input 2026-05-18 07:42:24 +02:00
Viktor Liu
bfb6750b13 Reset encoding capability flags on each SetEncodings 2026-05-18 07:41:42 +02:00
Viktor Liu
f5e1057127 Latin-1 round-trip for legacy CutText and soft-fail ext clipboard errors 2026-05-18 07:41:12 +02:00
Viktor Liu
ee393d0e62 Clamp Tight length to 22 bits and fall back to Raw on overflow 2026-05-17 21:27:13 +02:00
Viktor Liu
0b8fc5da59 Split session.go: encoder pipeline and clipboard handling into separate files 2026-05-17 17:32:01 +02:00
Viktor Liu
2d0a54f31a Fix golangci-lint and Sonar: drop newZlibState, extract applyEncoding, inline stub comment 2026-05-17 17:16:10 +02:00
Viktor Liu
61ec8d67de Honor QualityLevel and CompressLevel pseudo-encodings 2026-05-17 16:52:57 +02:00
Viktor Liu
76add0b9b2 Fix ExtendedClipboard auto-request by advertising all actions in Caps 2026-05-17 16:47:53 +02:00
Viktor Liu
a11341f57a Add ExtendedClipboard pseudo-encoding for UTF-8 bidirectional clipboard 2026-05-17 16:34:14 +02:00
Viktor Liu
b135d462d6 Drop unused zlibState.scratch field 2026-05-17 16:33:48 +02:00
Viktor Liu
da37a28951 Exclude VNC server from js, ios, and android builds 2026-05-17 15:48:15 +02:00
Viktor Liu
4f884d9f30 Add QEMU Extended Key Event for layout-independent input 2026-05-17 15:48:15 +02:00
Viktor Liu
2bed8b641b Lock pixel format to 32bpp little-endian truecolour and reject other formats 2026-05-17 15:48:15 +02:00
Viktor Liu
b4f696272a Drop unused VNC DES auth path 2026-05-17 15:48:15 +02:00
Viktor Liu
6d937af7a0 Drop dead Hextile and standalone Zlib encoding paths 2026-05-17 15:48:15 +02:00
Viktor Liu
db5b6cfbb7 Add DesktopSize, DesktopName, LastRect pseudo-encodings with resize detection 2026-05-17 15:48:15 +02:00
Viktor Liu
e75948753a Prompt for macOS Accessibility and Screen Recording at VNC enable time 2026-05-17 15:48:15 +02:00
Viktor Liu
047cc958b5 Throttle capture-failure log to once per 5s while capturer is down 2026-05-17 08:23:34 +02:00
Viktor Liu
cd005ef9a9 Add CopyRect detection and emission for tile-aligned moves 2026-05-17 08:13:52 +02:00
Viktor Liu
44ed0c1992 Drop xclip-no-selection trace log that fires every 2s on Xvfb 2026-05-17 08:13:46 +02:00
Viktor Liu
d6d3fa95c7 Drop unused getPeerFromResource helper 2026-05-17 06:48:46 +02:00
Viktor Liu
fa90283781 Extract wildcard user merge helper to satisfy case-clause length 2026-05-17 06:37:42 +02:00
Viktor Liu
8bf13b0d0c Merge SSH wildcard authorized users across matching rules 2026-05-17 06:33:27 +02:00
Viktor Liu
a8541a1529 Apply posture and validated-peers filtering on ResourceTypePeer policy resolution 2026-05-17 06:33:23 +02:00
Viktor Liu
94068d3ebc Drop -ac from Xvfb/Xorg invocations to keep xhost localuser grant authoritative 2026-05-17 06:32:50 +02:00
Viktor Liu
738c585ee7 Guard VNC session negotiated encoding state with RWMutex 2026-05-17 06:32:31 +02:00
Viktor Liu
9b5541d17d Extract session-address anonymization helper to lower status complexity 2026-05-16 22:11:28 +02:00
Viktor Liu
7123e6d1f4 Fix Windows lint errcheck/unused and Linux nilerr in console VNC fallback 2026-05-16 17:23:36 +02:00
Viktor Liu
62cf9e873b Track active VNC sessions in status and address CodeRabbit findings 2026-05-16 17:06:19 +02:00
Viktor Liu
9f0aa1ce26 Add embedded VNC server with JWT auth and per-peer toggle 2026-05-16 16:49:14 +02:00
304 changed files with 33708 additions and 4055 deletions

View File

@@ -3,8 +3,8 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 15
interval: "weekly"
open-pull-requests-limit: 3
groups:
actions:
patterns:
@@ -22,9 +22,12 @@ updates:
directories:
- "/"
schedule:
interval: "daily"
interval: "weekly"
open-pull-requests-limit: 15
groups:
golang-x-packages:
patterns:
- "golang.org/x/*"
aws-sdk:
patterns:
- "github.com/aws/aws-sdk-go-v2/*"

View File

@@ -2,6 +2,12 @@
## Issue ticket number and link
<!--
Required for anything that changes behavior. Link the issue (or the validated
discussion it came from) that the NetBird team already agreed on. See
https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second
-->
## Stack
<!-- branch-stack -->
@@ -12,7 +18,9 @@
- [ ] 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).
- [ ] I ran and tested this change locally — I did not rely on CI to find out whether it works
- [ ] This PR has a single purpose (not a fix + refactor + feature in one)
- [ ] This change is a trivial fix, **OR** it links an issue the NetBird team agreed on beforehand. Changes to the public API, gRPC protocols, functionality behavior, CLI / service flags, or new features always need that agreement first. See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second).
> By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

View File

@@ -1,6 +1,6 @@
# Contributing to NetBird
Thanks for your interest in contributing to NetBird.
Thanks for your interest in contributing to NetBird.
There are many ways that you can contribute:
- Reporting issues
@@ -10,12 +10,69 @@ There are many ways that you can contribute:
If you haven't already, join our slack workspace [here](https://docs.netbird.io/slack-url), we would love to discuss topics that need community contribution and enhancements to existing features.
## Ticket first, PR second
**Open a ticket and wait for feedback before you open a pull request.** Every PR
that changes behavior must link to an issue the NetBird team has agreed on. A PR
that arrives without one may be closed and redirected to a discussion, no matter
how good the code is.
Issues in this repository are maintainer-curated work items, so the flow starts
in [Discussions](https://github.com/netbirdio/netbird/discussions):
1. **Open a discussion.** Use
[Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage)
for a bug, regression, or unexpected behavior, and
[Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests)
for a feature, enhancement, or integration idea. Setup and usage questions
belong in
[Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support).
Never report a security vulnerability in public — follow the
[security policy](https://github.com/netbirdio/netbird/security/policy)
instead.
2. **Wait for feedback.** DevRel validates and reproduces the report, and a
maintainer confirms the direction. We may ask for more detail or propose a
different approach. Validated discussions become issues.
3. **Then write the code**, following the approach agreed in the issue, and open
the PR linking that issue.
Trivial fixes — a typo, a broken link, a documentation correction, or a one-line
fix that already has an issue — can go straight to a PR. Everything else starts
with a ticket. When in doubt, ask in the discussion or on
[Slack](https://docs.netbird.io/slack-url); an hour of conversation up front
regularly saves a week of rework.
### High-risk areas
These always need the design discussed and agreed in the issue **before** you
write code:
- **Public API** — REST / management API, OpenAPI schema, dashboard-facing contracts
- **gRPC protocols** — management, signal, relay, and client daemon protos
- **Functionality behavior** — anything existing deployments would experience differently after an upgrade
- **Peer connectivity** — ICE and NAT traversal, relay selection, WireGuard® and Rosenpass key handling
- **Client system integration** — routing, firewall, DNS, and interface management
- **Authentication and authorization** — IdP integration, tokens, permissions, cryptography
- **CLI / service flags**, configuration file format, and daemon IPC
- **Store and database schema** — models and migrations
- **New features**
These surfaces are NetBird's contract with operators, self-hosters, and
downstream integrators, and changes to them have compatibility, security, and
release-planning implications. Agreeing on the direction early lets the PR
review focus on implementation rather than design.
Typical bug fixes, internal refactors, documentation updates, and tests do not
need a design discussion, but should still be tied to an issue so the work is
visible and nobody duplicates it.
## Contents
- [Contributing to NetBird](#contributing-to-netbird)
- [Ticket first, PR second](#ticket-first-pr-second)
- [High-risk areas](#high-risk-areas)
- [Contents](#contents)
- [Code of conduct](#code-of-conduct)
- [Discuss changes with the NetBird team first](#discuss-changes-with-the-netbird-team-first)
- [Directory structure](#directory-structure)
- [Development setup](#development-setup)
- [Requirements](#requirements)
@@ -24,6 +81,7 @@ If you haven't already, join our slack workspace [here](https://docs.netbird.io/
- [Build and start](#build-and-start)
- [Test suite](#test-suite)
- [Checklist before submitting a PR](#checklist-before-submitting-a-pr)
- [When we close a PR](#when-we-close-a-pr)
- [Other project repositories](#other-project-repositories)
- [Contributor License Agreement](#contributor-license-agreement)
@@ -34,42 +92,66 @@ Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report
unacceptable behavior to community@netbird.io.
## Discuss changes with the NetBird team first
Changes to the **public API**, **gRPC protocols**, **functionality behavior**, **CLI / service flags**, or **new features** should be discussed with the NetBird team before you start the work. These surfaces are part of NetBird's contract with operators, self-hosters, and downstream integrators, and changes to them have compatibility, security, and release-planning implications that benefit from an early conversation.
Open an issue or reach out on [Slack](https://docs.netbird.io/slack-url) to talk through what you have in mind. We'll help shape the change, flag any constraints we know about, and confirm the direction so the PR review can focus on implementation rather than design.
Typical bug fixes, internal refactors, documentation updates, and tests do not need pre-discussion — open the PR directly.
## Directory structure
The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages.
The NetBird project monorepo keeps most of each component's code within its own
directory, except for a few auxiliary or shared packages. Protocol definitions
and the client-side service clients live under [/shared](/shared), because both
the agent and the services import them.
The most important directories are:
**Agent**
- [/.github](/.github) - Github actions workflow files and issue templates
- [/client](/client) - NetBird agent code
- [/client/cmd](/client/cmd) - NetBird agent cli code
- [/client/cmd](/client/cmd) - NetBird agent CLI code
- [/client/internal](/client/internal) - NetBird agent business logic code
- [/client/proto](/client/proto) - NetBird agent daemon GRPC proto files
- [/client/server](/client/server) - NetBird agent daemon code for background execution
- [/client/ui](/client/ui) - NetBird agent UI code
- [/encryption](/encryption) - Contain main encryption code for agent communication
- [/iface](/iface) - Wireguard® interface code
- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts
- [/client/proto](/client/proto) - NetBird agent daemon gRPC proto files
- [/client/iface](/client/iface) - WireGuard® interface code
- [/client/firewall](/client/firewall) - Platform firewall backends (nftables, iptables, pf, WFP, userspace)
- [/client/ssh](/client/ssh) - Built-in SSH server and client
- [/client/ui](/client/ui) - NetBird agent UI code (Wails v3 + React)
- [/client/android](/client/android), [/client/ios](/client/ios) - Mobile platform bindings
- [/client/wasm](/client/wasm) - WebAssembly build of the agent
- [/client/mdm](/client/mdm) - MDM-delivered policy handling
- [/client/system](/client/system) - Host and system information collection
**Control plane services**
- [/management](/management) - Management service code
- [/management/client](/management/client) - Management service client code which is imported by the agent code
- [/management/proto](/management/proto) - Management service GRPC proto files
- [/management/server](/management/server) - Management service server code
- [/management/server/http](/management/server/http) - Management service REST API code
- [/management/server/store](/management/server/store) - Persistence layer and migrations
- [/management/server/idp](/management/server/idp) - Management service IDP management code
- [/release_files](/release_files) - Files that goes into release packages
- [/management/server/peer](/management/server/peer), [/management/server/groups](/management/server/groups), [/management/server/networks](/management/server/networks), [/management/server/posture](/management/server/posture), [/management/server/permissions](/management/server/permissions) - Core domain packages
- [/signal](/signal) - Signal service code
- [/signal/client](/signal/client) - Signal service client code which is imported by the agent code
- [/signal/peer](/signal/peer) - Signal service peer message logic
- [/signal/proto](/signal/proto) - Signal service GRPC proto files
- [/signal/server](/signal/server) - Signal service server code
- [/relay](/relay) - Relay service code
- [/relay/protocol](/relay/protocol) - Relay wire protocol
- [/proxy](/proxy) - Identity-aware proxy used by Agent Network (LLM routing, ACME, access logs)
- [/agent-network](/agent-network) - Agent Network overview and documentation
- [/upload-server](/upload-server) - Debug bundle upload service
**Shared code**
- [/shared/management/proto](/shared/management/proto) - Management service gRPC proto files
- [/shared/management/client](/shared/management/client) - Management service client code which is imported by the agent code
- [/shared/management/http/api](/shared/management/http/api) - OpenAPI specification and generated REST API types
- [/shared/signal/proto](/shared/signal/proto) - Signal service gRPC proto files
- [/shared/signal/client](/shared/signal/client) - Signal service client code which is imported by the agent code
- [/shared/relay](/shared/relay) - Relay client and shared relay types
- [/shared/auth](/shared/auth), [/shared/sshauth](/shared/sshauth) - Shared authentication primitives
- [/encryption](/encryption) - Contain main encryption code for agent communication
- [/dns](/dns), [/route](/route), [/stun](/stun), [/sharedsock](/sharedsock), [/util](/util) - Shared networking and utility primitives
- [/flow](/flow) - Flow event protocol shared by the agent and Management
**Build, test, and packaging**
- [/.github](/.github) - Github actions workflow files, issue templates, and the pull request template
- [/e2e](/e2e) - End-to-end test suites and harness
- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts
- [/release_files](/release_files) - Files that goes into release packages
- [/tools](/tools) - Development and maintenance tooling
## Development setup
@@ -234,12 +316,22 @@ cd client/ui
task dev
```
Pass daemon flags after `--`:
Pass daemon flags after `--`, pointing the UI at the socket the daemon serves:
```
task dev -- --daemon-addr=tcp://127.0.0.1:41731
task dev -- --daemon-addr=unix:///var/run/netbird.sock # Linux, macOS
task dev -- --daemon-addr=npipe://netbird # Windows
```
On Windows the daemon serves a named pipe (`npipe://netbird`). Which path that
ends up being depends on what the daemon may create: as a service or elevated it
serves `\\.\pipe\ProtectedPrefix\Administrators\netbird`, which no unprivileged
process can take from it, and otherwise it falls back to `\\.\pipe\netbird`.
Clients try both and check who owns the pipe before using the plain one. Avoid
`tcp://127.0.0.1:41731`: loopback TCP carries no caller identity, so the daemon
refuses the operations that require an administrator and you will not exercise
those paths.
Production build (frontend assets embedded into the binary, output in `client/ui/bin/`):
```
@@ -324,23 +416,172 @@ The installer `netbird-installer.exe` will be created in root directory.
### Test suite
The tests can be started via:
The host-safe unit tests run as a normal user and leave host networking
untouched:
```
cd netbird
go test -exec sudo ./...
make test-unit
```
Tests that need root and mutate host networking (firewall, routing, interface
management) carry the `privileged` build tag and run inside a
`--privileged --cap-add=NET_ADMIN` Docker container:
```
make test-privileged
```
Narrow a privileged run with environment variables:
```
PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
```
Single packages can be run directly, adding `-race` when the change touches
shared state:
```
go test -race ./client/internal/dns/...
```
> On Windows use a powershell with administrator privileges
## Checklist before submitting a PR
As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
As a critical network service and open-source project, we must enforce a few
things before submitting a pull request. The
[pull request template](/.github/pull_request_template.md) mirrors this list —
fill it in rather than deleting it.
### Link the issue
The PR description must link the agreed issue (or the validated discussion it
came from). See [Ticket first, PR second](#ticket-first-pr-second).
### Run it locally
**If you can't run it, you can't submit it.** Build the affected components and
exercise the change on a real setup — see [Build and start](#build-and-start).
"CI will tell me" is not acceptable for a VPN agent that runs as root on other
people's machines.
### Green CI, and answer the bots
We do not start reviewing while CI is red. Get the pipeline green first — a
failing build, lint, or test means the PR is not ready for review.
Alongside the test workflows, your PR is reviewed by CodeRabbit and scanned by
SonarCloud, Snyk, and Codecov. Read what they report and either fix it or reply
with why it does not apply; please do not resolve the threads without a
response. They are not always right — this codebase has privileged,
platform-specific, and concurrency-heavy paths that static analysis reads poorly
— so push back when a finding is wrong rather than changing correct code to
silence it. Security and dependency findings are the exception: treat those as
real until shown otherwise. Do not edit workflows, thresholds, or scanner
configuration to make a check pass.
### One PR, one purpose
Bug fix, refactor, feature: separate PRs. Mixed PRs are slow to review, hard to
revert, and may be closed with a request to split them.
### Keep it small
Size is the strongest predictor of how long a PR waits for review. Aim for under
roughly 400 changed lines across under 20 files. Past about 1000 lines or 50
files, expect to be asked to split the change — and large PRs from outside the
core team may be blocked until the scope has been agreed in a ticket. This is
not only about reviewer time: NetBird's agent runs as root on other people's
machines, and a sprawling diff cannot be reviewed with the care that deserves.
Measure by hand-written code, excluding generated output, `go.sum`, and
fixtures. If a change genuinely cannot be small — a protocol migration, a
cross-component rename — agree the split in the issue before you start, and land
it as a series of PRs that each build and make sense on their own.
### Avoid force-pushing during review
Once a PR is open, push new commits instead of rewriting history. A force-push
detaches existing review comments from their lines, throws away the
"changes since your last review" diff, and loses the CI history that showed
which commit broke what. Since we squash on merge, there is nothing to gain from
a tidy branch history.
Force-pushing is sometimes unavoidable — rebasing to clear a real conflict, or
removing a secret or large binary committed by mistake. When that happens, leave
a comment on the PR so reviewers know their anchors moved.
### Quality checks
Run these from the repository root before pushing:
```shell
go fmt ./...
make lint # golangci-lint on files changed against origin/main
make lint-all # full-repository lint, matches CI
make test-unit # host-safe unit tests
```
`make setup-hooks` wires `make lint` into a pre-push hook so the fast lint runs
automatically. If your change touches privileged paths (firewall, routing,
interface management), also run `make test-privileged`, which executes the
`privileged`-tagged suite inside a Docker container with `NET_ADMIN`.
### Code standards
- Keep functions as simple as possible, with a single purpose
- Use private functions and constants where possible
- Comment on any new public functions
- Add unit tests for any new public function
- Comment the **why**, not the **what** — explain non-obvious decisions, invariants, and constraints, not the line below
- Keep comments within 90 characters per line and roughly 250 characters per comment; when a block needs more explanation than that, extract a named function instead of writing a longer comment (see [AGENTS.md](AGENTS.md#length-budget))
### PR title and commits
PR titles must start with a bracketed tag, enforced by
[pr-title-check.yml](/.github/workflows/pr-title-check.yml):
```text
[client] Authorize daemon IPC callers by their local identity
[management,client] Add MDM policy support
```
Use a comma-separated list inside a single pair of brackets when a change spans
components. The `allowedTags` array in
[pr-title-check.yml](/.github/workflows/pr-title-check.yml) is the source of
truth — at the time of writing it accepts `management`, `client`, `signal`,
`proxy`, `relay`, `misc`, `infrastructure`, `self-hosted`, and `doc`.
Commit subjects follow the same convention — keep them short and put the
reasoning in the body, why before what, with no bullet list of files changed.
Keep the PR description itself under 1000 words on top of the template text.
Reviewers read the diff; the description explains what the diff cannot.
> When pushing fixes to the PR comments, please push as separate commits; we will squash the PR before merging, so there is no need to squash it before pushing it, and we are more than okay with 10-100 commits in a single PR. This helps review the fixes to the requested changes.
### Documentation
User-facing changes need a matching PR in
[netbirdio/docs](https://github.com/netbirdio/docs); link it in the PR
description, or state why documentation is not needed.
## When we close a PR
We would rather redirect early than let a PR sit. We may close one if:
- It changes behavior with no linked issue, or the approach was never agreed with a maintainer
- The change was clearly never run or tested locally
- CI has been red without a response
- It mixes unrelated purposes, or the purpose is not clear
- It is far too large to review and the scope was never agreed in a ticket
- The author cannot answer questions about their own change — including PRs that read as unreviewed model output, where review turns into a relay between the maintainer and an LLM. Tooling is fine; unreviewed output is not, you are responsible for the code you sign your name to
- There has been no activity for 14 days after we requested changes
A closed PR is not a rejected idea. Take it back to the
[discussion](https://github.com/netbirdio/netbird/discussions), settle the
approach, and reopen the work from there.
## Other project repositories
NetBird project is composed of 3 main repositories:

View File

@@ -1,12 +1,70 @@
# Security Policy
NetBird's goal is to provide a secure network. If you find a vulnerability or bug, please report it by opening an issue [here](https://github.com/netbirdio/netbird/issues/new?assignees=&labels=&template=bug-issue-report.md&title=) or by contacting us by email.
There has yet to be an official bug bounty program for the NetBird project.
## Supported Versions
- We currently support only the latest version
NetBird's goal is to provide a secure network. The client runs as a privileged service on every machine it is installed on,
so we take reports about it seriously and we publish what we fix.
## Reporting a Vulnerability
Please report security issues to `security@netbird.io`
**Please do not open a public issue for a security vulnerability.** Public issues are visible to everyone, including before
a fix is available.
Report security issues one of these two ways:
- **GitHub private vulnerability reporting** — [open a private report](https://github.com/netbirdio/netbird/security/advisories/new)
on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place.
- **Email** — `security@netbird.io`.
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open-source code, email us rather than
filing a repository report.
### What to include
A report is easier to act on when it contains:
- The affected component (client, management, signal, relay, dashboard) and the version or commit you tested
- The platform and configuration, where relevant — operating system, self-hosted or NetBird Cloud, container or host install
- What an attacker needs before they can exploit it: network position, an account, local access, a specific privilege level
- Steps to reproduce, and a proof of concept if you have one
- The impact you believe it has
Partial reports are still welcome. If you are unsure whether something is a security issue, send it to `security@netbird.io`
and let us make that call.
## What to expect from us
- **We acknowledge your report** and tell you whether we can reproduce it.
- **We work with you on severity and scope.** If we assess it differently than you do, we will explain why rather than
silently downgrade it.
- **We fix and release**, then publish a [GitHub Security Advisory](https://github.com/netbirdio/netbird/security/advisories)
naming the affected version range and the patched version.
- **We credit reporters who want to be credited.** Tell us the name or handle you would like used, or that you would rather
stay anonymous.
- **We keep you in the loop** until the advisory is published.
We ask that you give us a reasonable opportunity to ship a fix before disclosing the issue publicly, and that you avoid
accessing, modifying, or exfiltrating data belonging to other people while testing. Testing against your own installation
or your own account is always fine.
## Supported Versions
We support the latest release. Security fixes ship in the next version rather than as backports to older releases, so
upgrading to the current release is how you get them.
Release notifications are available by watching [releases](https://github.com/netbirdio/netbird/releases).
## Published advisories
Every vulnerability we fix is published as a GitHub Security Advisory on the
[advisories page](https://github.com/netbirdio/netbird/security/advisories), including the affected version range, the
patched version, and the reporter's credit. Advisories for the Go module are also distributed through the Go vulnerability
database, so `govulncheck` will report them against your dependencies.
## Bug bounty
There is no official bug bounty program for the NetBird project. We credit reporters in advisories, and we are grateful for
the work, but we cannot currently offer payment for reports.
## Non-security bugs
For bugs that are not security issues, please use the
[issue tracker](https://github.com/netbirdio/netbird/discussions/new/choose).

View File

@@ -76,6 +76,24 @@ type Client struct {
connectClient *internal.ConnectClient
config *profilemanager.Config
cacheDir string
stateChangeMu sync.Mutex
stateChangeSubID string
eventSub *peer.EventSubscription
// Closed to stop the watch goroutines from delivering buffered items to a
// listener that has been removed or replaced. See stopStateChangeWatchLocked.
stateChangeDone chan struct{}
// Latched "the server wants an interactive login": survives the engine
// restarts that replace the run loop's context state. See Client.Status.
// Guarded by loginRequiredMu together with loginCleared, which counts
// clears so a stale observation cannot re-latch over one.
loginRequiredMu sync.Mutex
loginRequired bool
loginCleared uint64
extendMu sync.Mutex
extendCancel context.CancelFunc
}
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
@@ -149,11 +167,16 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
if err != nil {
return err
}
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
c.setState(cfg, cacheDir, connectClient)
// This path runs the interactive SSO flow, so reaching here means the peer
// is authenticated again — release the latch Status() reports from. Clear
// only once the fresh connect client is installed: until then Status()
// still reads the previous run's context state, which holds the NeedsLogin
// that prompted this login, and would re-latch what was just cleared.
c.clearLoginRequired()
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -278,7 +301,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path)
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false)
if err != nil {
return "", fmt.Errorf("upload debug bundle: %w", err)
}

309
client/android/session.go Normal file
View File

@@ -0,0 +1,309 @@
//go:build android
package android
import (
"context"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
"github.com/netbirdio/netbird/client/internal/peer"
cProto "github.com/netbirdio/netbird/client/proto"
)
// StateChangeListener receives client state notifications.
//
// OnStateChanged is a payload-free wake-up whenever the state snapshot
// changed: connection state, the run-loop status label (e.g. NeedsLogin) or
// the session deadline. It mirrors the daemon's SubscribeStatus stream
// trigger — on each signal the consumer pulls the fresh values via
// Status() / SessionExpiresAtUnix().
//
// OnSessionExpiring forwards the engine's session-expiry warnings, fired at
// sessionwatch.WarningLead before the deadline and again at FinalWarningLead
// (finalWarning true). The second one is suppressed when the user dismissed
// the first via DismissSessionWarning. The daemon turns the same events into
// its tray notification.
type StateChangeListener interface {
OnStateChanged()
OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool)
}
// Status returns the connect run-loop's status label — the same value the
// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the
// management server rejected the peer and an interactive login is required.
//
// The label is latched: the run loop keeps its status in a per-run context
// state, which a restart replaces with a fresh Idle one, so an engine restart
// (network change, always-on) would otherwise erase the fact that the peer
// still needs to log in. Only a successful interactive login or extend clears
// it — see clearLoginRequired.
func (c *Client) Status() string {
latched, generation := c.loginRequiredState()
if latched {
return string(internal.StatusNeedsLogin)
}
cc := c.getConnectClient()
if cc == nil {
return string(internal.StatusIdle)
}
status := cc.Status()
if status == internal.StatusNeedsLogin {
c.latchLoginRequired(generation)
}
return string(status)
}
func (c *Client) loginRequiredState() (bool, uint64) {
c.loginRequiredMu.Lock()
defer c.loginRequiredMu.Unlock()
return c.loginRequired, c.loginCleared
}
// latchLoginRequired records a NeedsLogin observation, unless a clear landed
// while the caller was reading the run loop's status: cc.Status() is read
// outside the lock, so a login or extend completing in that window would
// otherwise be undone by this stale observation, stranding the UI on
// "login required" over a healthy session.
func (c *Client) latchLoginRequired(observedGeneration uint64) {
c.loginRequiredMu.Lock()
defer c.loginRequiredMu.Unlock()
if c.loginCleared != observedGeneration {
return
}
c.loginRequired = true
}
// clearLoginRequired releases the latch after a successful interactive login
// or session extend, and invalidates any observation already in flight.
func (c *Client) clearLoginRequired() {
c.loginRequiredMu.Lock()
defer c.loginRequiredMu.Unlock()
c.loginRequired = false
c.loginCleared++
}
// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0
// when no deadline is known (not SSO-registered, expiry disabled, or the
// engine has not received one yet). A past value means the session expired.
// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon.
func (c *Client) SessionExpiresAtUnix() int64 {
deadline := c.recorder.GetSessionExpiresAt()
if deadline.IsZero() {
return 0
}
return deadline.Unix()
}
// SetStateChangeListener registers the state notification listener.
// Replaces any previously registered listener; remove it with
// RemoveStateChangeListener.
func (c *Client) SetStateChangeListener(listener StateChangeListener) {
c.stateChangeMu.Lock()
defer c.stateChangeMu.Unlock()
c.stopStateChangeWatchLocked()
if listener == nil {
return
}
// Both subscriptions are buffered (one pending tick, ten pending events),
// so unsubscribing is not enough to stop callbacks: the loops would drain
// what is already queued and deliver it to a listener the caller has
// already removed or replaced. Gate every callback on this registration's
// own signal, which is closed before unsubscribing.
done := make(chan struct{})
c.stateChangeDone = done
id, ch := c.recorder.SubscribeToStateChanges()
c.stateChangeSubID = id
// The channel is closed by UnsubscribeFromStateChanges, which ends the
// goroutine. Ticks are coalesced (buffer of one), so a burst of changes
// wakes the listener once.
go func() {
for range ch {
select {
case <-done:
return
default:
}
listener.OnStateChanged()
}
}()
c.eventSub = c.recorder.SubscribeToEvents()
go watchSessionWarnings(c.eventSub, listener, done)
}
// RemoveStateChangeListener unregisters the state notification listener.
func (c *Client) RemoveStateChangeListener() {
c.stateChangeMu.Lock()
defer c.stateChangeMu.Unlock()
c.stopStateChangeWatchLocked()
}
// DismissSessionWarning records the user's "Dismiss" on the first expiry
// warning and suppresses the final one for the current deadline. A refreshed
// deadline re-arms both. No-op while the engine is not running.
func (c *Client) DismissSessionWarning() {
cc := c.getConnectClient()
if cc == nil {
return
}
engine := cc.Engine()
if engine == nil {
return
}
engine.DismissSessionWarning()
}
// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and
// asks the management server to extend the session deadline. The tunnel is
// untouched: no resync, no reconnect. Async; the result arrives on the
// listener. Mirror of the daemon's RequestExtendAuthSession /
// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the
// browser" role.
//
// Only one flow may be in flight: the PKCE step binds a fixed loopback port,
// so a second concurrent flow would fail on that bind. Call
// CancelExtendAuthSession when the user abandons the browser.
func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) {
ctx, err := c.beginExtend()
if err != nil {
resultListener.OnError(err)
return
}
go func() {
defer c.endExtend()
if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil {
resultListener.OnError(err)
return
}
resultListener.OnSuccess()
}()
}
// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is
// left alone — unlike the login flow, which cancels the whole client context
// by stopping the engine. Without this the abandoned PKCE wait keeps its
// loopback port for the full flow timeout and blocks every later attempt.
// No-op when no flow is running.
func (c *Client) CancelExtendAuthSession() {
c.extendMu.Lock()
defer c.extendMu.Unlock()
if c.extendCancel != nil {
c.extendCancel()
}
}
func (c *Client) stopStateChangeWatchLocked() {
// Signal first, unsubscribe second: closing the channels only stops new
// items, and the loops would still hand whatever is buffered to a listener
// that is no longer registered.
if c.stateChangeDone != nil {
close(c.stateChangeDone)
c.stateChangeDone = nil
}
if c.stateChangeSubID != "" {
c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID)
c.stateChangeSubID = ""
}
if c.eventSub != nil {
// Closes the channel, which ends watchSessionWarnings.
c.recorder.UnsubscribeFromEvents(c.eventSub)
c.eventSub = nil
}
}
// watchSessionWarnings forwards the engine's session-expiry warnings to the
// listener. The event stream also carries unrelated traffic — network-map
// updates on every sync, DNS and route errors — so everything but an
// AUTHENTICATION event carrying the session-warning marker is dropped. Exits
// when the subscription is closed by UnsubscribeFromEvents, or earlier when
// done is closed — the stream buffers up to ten events, and a deregistered
// listener must not receive the ones already queued.
func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) {
for ev := range sub.Events() {
select {
case <-done:
return
default:
}
if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION {
continue
}
meta := ev.GetMetadata()
if meta[sessionwatch.MetaSessionWarning] != "true" {
// Other AUTHENTICATION events exist (e.g. a deadline rejected as
// out of range); they carry no warning marker.
continue
}
deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt])
if err != nil {
log.Warnf("session warning event with unparsable deadline: %v", err)
continue
}
lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes])
if err != nil {
// Informational only — the deadline above is what drives the UI.
lead = 0
}
listener.OnSessionExpiring(deadline.Unix(), int64(lead),
meta[sessionwatch.MetaSessionFinal] == "true")
}
}
func (c *Client) beginExtend() (context.Context, error) {
c.extendMu.Lock()
defer c.extendMu.Unlock()
if c.extendCancel != nil {
return nil, fmt.Errorf("session extend already in progress")
}
ctx, cancel := context.WithCancel(context.Background())
c.extendCancel = cancel
return ctx, nil
}
func (c *Client) endExtend() {
c.extendMu.Lock()
defer c.extendMu.Unlock()
if c.extendCancel != nil {
c.extendCancel()
c.extendCancel = nil
}
}
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
cfg, _, cc := c.stateSnapshot()
if cfg == nil || cc == nil {
return fmt.Errorf("engine is not running")
}
engine := cc.Engine()
if engine == nil {
return fmt.Errorf("engine is not initialized")
}
authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg)
if err != nil {
return fmt.Errorf("failed to create auth client: %v", err)
}
defer authClient.Close()
a := &Auth{ctx: ctx, config: cfg}
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil {
return err
}
c.clearLoginRequired()
go urlOpener.OnLoginSuccess()
return nil
}

View File

@@ -0,0 +1,66 @@
package cmd
import (
"errors"
"fmt"
"strings"
"google.golang.org/genproto/googleapis/rpc/errdetails"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// daemonCallError prepares a daemon error for display. A refusal the daemon
// raised because the operation needs root/administrator is already guidance
// written for the user, so it is surfaced on its own instead of buried under the
// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped
// with context as usual.
func daemonCallError(context string, err error) error {
if guidance, ok := privilegeGuidance(err); ok {
return errors.New(guidance)
}
return fmt.Errorf("%s: %w", context, err)
}
// privilegeGuidance renders the daemon's privilege refusal as a summary and the
// command that performs the operation with the privileges it needs. It reports
// false for any other error.
func privilegeGuidance(err error) (string, bool) {
info, ok := privilegeErrorInfo(err)
if !ok {
return "", false
}
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
command := info.GetMetadata()[ipcauth.ErrorMetaCommand]
if summary == "" {
// Detail without a summary: fall back to the status message, which
// carries the same text.
summary = strings.TrimSpace(gstatus.Convert(err).Message())
}
if command == "" {
return summary, true
}
return fmt.Sprintf("%s\n\n %s\n", summary, command), true
}
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
// carries one.
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
if err == nil {
return nil, false
}
for _, detail := range gstatus.Convert(err).Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if !ok {
continue
}
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
return info, true
}
}
return nil, false
}

View File

@@ -29,8 +29,9 @@ const errCloseConnection = "Failed to close connection: %v"
var (
logFileCount uint32
systemInfoFlag bool
uploadBundleFlag bool
uploadBundleURLFlag string
uploadBundleFlag bool
uploadBundleURLFlag string
uploadBundleInsecureFlag bool
)
var debugCmd = &cobra.Command{
@@ -174,10 +175,11 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
request.UploadInsecure = uploadBundleInsecureFlag
}
resp, err := client.DebugBundle(cmd.Context(), request)
if err != nil {
return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message())
return daemonCallError("bundle debug", err)
}
cmd.Printf("Local file:\n%s\n", resp.GetPath())
@@ -373,10 +375,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
request.UploadInsecure = uploadBundleInsecureFlag
}
resp, err := client.DebugBundle(cmd.Context(), request)
if err != nil {
return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message())
return daemonCallError("bundle debug", err)
}
if needsRestoreUp {
@@ -524,10 +527,12 @@ func init() {
debugBundleCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
debugBundleCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
debugBundleCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
debugBundleCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Uint32VarP(&logFileCount, "log-file-count", "C", 1, "Number of rotated log files to include in debug bundle")
forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle")
}

View File

@@ -46,7 +46,7 @@ var logoutCmd = &cobra.Command{
}
if _, err := daemonClient.Logout(ctx, req); err != nil {
return fmt.Errorf("deregister: %v", err)
return daemonCallError("deregister", err)
}
cmd.Println("Deregistered successfully")

View File

@@ -20,7 +20,6 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -91,6 +90,7 @@ var (
// Don't resolve for service commands — they create the socket, not connect to it.
if !isServiceCmd(cmd) {
daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr)
daemonAddr = daddr.ResolveDaemonAddr(daemonAddr)
}
return nil
},
@@ -143,10 +143,10 @@ func init() {
defaultDaemonAddr := "unix:///var/run/netbird.sock"
if runtime.GOOS == "windows" {
defaultDaemonAddr = "tcp://127.0.0.1:41731"
defaultDaemonAddr = daddr.WindowsPipeAddr
}
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")
@@ -269,12 +269,10 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e
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, opts := daddr.DialTarget(addr)
opts = append(opts, grpc.WithBlock())
return grpc.DialContext(ctx, target, opts...)
}
// WithBackOff execute function in backoff cycle.

View File

@@ -33,10 +33,15 @@ var (
)
type program struct {
ctx context.Context
cancel context.CancelFunc
serv *grpc.Server
jsonServ *http.Server
ctx context.Context
cancel context.CancelFunc
serv *grpc.Server
jsonServ *http.Server
// jsonClient is the gateway's own connection to the daemon. It is held so
// shutting the gateway down also closes it: nothing else references it once
// the handlers are registered, so its transport goroutines would otherwise
// outlive the server.
jsonClient *grpc.ClientConn
jsonServMu sync.Mutex
serverInstance *server.Server
serverInstanceMu sync.Mutex

View File

@@ -5,6 +5,7 @@ package cmd
import (
"context"
"fmt"
"runtime"
"time"
"github.com/kardianos/service"
@@ -13,6 +14,8 @@ import (
"github.com/spf13/cobra"
"google.golang.org/grpc"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"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"
@@ -26,6 +29,31 @@ func validateJSONSocketFlags() error {
return nil
}
// daemonServerOptions installs the transport credentials that expose each
// caller's kernel-authenticated identity to the handlers, which is what lets
// the daemon require root/administrator for privileged operations.
//
// The handshake exchanges no bytes, so older CLI and UI binaries still
// interoperate. Callers on a TCP socket carry no identity at all: the daemon
// keeps serving them, and the privileged operations deny them, so a warning is
// logged to make the loss of functionality visible.
func daemonServerOptions(network string) []grpc.ServerOption {
if network == "tcp" {
log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
"so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+
"deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr)
return nil
}
creds := ipcauth.NewTransportCredentials()
if creds == nil {
log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS)
return nil
}
return []grpc.ServerOption{grpc.Creds(creds)}
}
func (p *program) Start(svc service.Service) error {
// Start should not block. Do the actual work async.
log.Info("starting NetBird service") //nolint
@@ -37,68 +65,106 @@ 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()
daemonListener, err := listenOnAddress(daemonAddr)
if err != nil {
return fmt.Errorf("listen daemon interface: %w", err)
// A daemon installed before named-pipe support has the loopback TCP address
// persisted. Move it to the named pipe so an upgraded daemon can identify
// its callers instead of silently serving an unauthenticated socket.
if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated)
daemonAddr = migrated
}
var jsonListener *socketListener
if enableJSONSocket {
jsonListener, err = listenOnAddress(jsonSocket)
if err != nil {
_ = daemonListener.Close()
return fmt.Errorf("listen daemon JSON interface: %w", err)
}
} else {
removeStaleUnixSocketForAddress(jsonSocket)
network, _, err := parseListenAddress(daemonAddr)
if err != nil {
return fmt.Errorf("parse daemon address: %w", err)
}
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
p.serv = grpc.NewServer(daemonServerOptions(network)...)
daemonListener, jsonListener, err := listenDaemonSockets()
if err != nil {
return err
}
go func() {
defer daemonListener.Close()
if jsonListener != nil {
defer jsonListener.Close()
}
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
log.Error(err)
return
}
if jsonListener != nil {
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
log.Error(err)
return
}
}
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
if err := serverInstance.Start(); err != nil {
log.Fatalf("failed to start daemon: %v", err)
}
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
p.serverInstanceMu.Lock()
p.serverInstance = serverInstance
p.serverInstanceMu.Unlock()
if jsonListener != nil {
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
log.Fatalf("failed to start daemon JSON server: %v", err)
}
} else {
log.Debug("daemon JSON socket disabled")
}
log.Printf("started daemon server: %v", daemonListener.address)
if err := p.serv.Serve(daemonListener.Listener); err != nil {
log.Errorf("failed to serve daemon requests: %v", err)
// Fatal here rather than inside serve, so serve's deferred listener
// closes run before the process exits.
if err := p.serve(daemonListener, jsonListener); err != nil {
log.Fatalf("failed to %v", err)
}
}()
return nil
}
// listenDaemonSockets opens the daemon control socket and, when it is enabled, the
// JSON gateway socket. The control socket is closed again if the second one fails,
// so a failed start leaves nothing listening. The returned JSON listener is nil
// when the socket is disabled.
func listenDaemonSockets() (*socketListener, *socketListener, error) {
daemonListener, err := listenOnAddress(daemonAddr)
if err != nil {
return nil, nil, fmt.Errorf("listen daemon interface: %w", err)
}
if !enableJSONSocket {
removeStaleUnixSocketForAddress(jsonSocket)
return daemonListener, nil, nil
}
jsonListener, err := listenOnAddress(jsonSocket)
if err != nil {
if cerr := daemonListener.Close(); cerr != nil {
log.Debugf("close daemon listener: %v", cerr)
}
return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err)
}
return daemonListener, jsonListener, nil
}
// serve brings up the daemon server on an already-open control socket and blocks
// until it stops. jsonListener is nil when the JSON socket is disabled. A returned
// error means the daemon cannot run at all and the caller is expected to exit; the
// failures it recovers from on its own are logged here.
func (p *program) serve(daemonListener, jsonListener *socketListener) error {
defer daemonListener.Close()
if jsonListener != nil {
defer jsonListener.Close()
}
// chmodUnixSocket is a no-op for a nil listener and for a non-unix one.
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
log.Error(err)
return nil
}
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
log.Error(err)
return nil
}
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
if err := serverInstance.Start(); err != nil {
return fmt.Errorf("start daemon: %w", err)
}
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
p.serverInstanceMu.Lock()
p.serverInstance = serverInstance
p.serverInstanceMu.Unlock()
if jsonListener == nil {
log.Debug("daemon JSON socket disabled")
} else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
return fmt.Errorf("start daemon JSON server: %w", err)
}
log.Printf("started daemon server: %v", daemonListener.address)
if err := p.serv.Serve(daemonListener.Listener); err != nil {
log.Errorf("failed to serve daemon requests: %v", err)
}
return nil
}
func (p *program) Stop(srv service.Service) error {
p.serverInstanceMu.Lock()
if p.serverInstance != nil {
@@ -113,8 +179,13 @@ func (p *program) Stop(srv service.Service) error {
p.cancel()
p.jsonServMu.Lock()
jsonServ := p.jsonServ
jsonServ, jsonClient := p.jsonServ, p.jsonClient
p.jsonServMu.Unlock()
if jsonClient != nil {
if err := jsonClient.Close(); err != nil {
log.Debugf("close daemon JSON gateway client: %v", err)
}
}
if jsonServ != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := jsonServ.Shutdown(shutdownCtx); err != nil {

View File

@@ -5,27 +5,123 @@ package cmd
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
func grpcGatewayEndpoint(addr string) string {
return strings.TrimPrefix(addr, "tcp://")
// jsonPeerIdentity is the context key under which the connecting HTTP client's
// identity is stashed for the lifetime of its connection.
type jsonPeerIdentity struct{}
// jsonPeerIdentityValue pairs the identity with whether it could be read at
// all, so an unreadable identity is forwarded as "unknown" rather than omitted.
type jsonPeerIdentityValue struct {
id ipcauth.Identity
known bool
}
// jsonConnContext reads the identity of the client connecting to the JSON
// socket and stashes it on the connection's context. The gateway re-dials the
// daemon in-process, so the daemon would otherwise see every JSON request as
// coming from the daemon itself.
func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
value := jsonPeerIdentityValue{}
id, err := ipcauth.ConnIdentity(c)
if err != nil {
log.Warnf("json gateway: cannot read HTTP client identity, privileged operations will be denied for this connection: %v", err)
} else {
value.id = id
value.known = true
}
return context.WithValue(ctx, jsonPeerIdentity{}, value)
}
// forwardIdentity stamps the HTTP client's identity onto every call the gateway
// makes to the daemon.
//
// It is an interceptor on the gateway's client connection rather than a
// runtime.WithMetadata annotator because grpc-gateway skips annotators when no
// request header maps to metadata, which an HTTP/1.0 request with no Host header
// over a unix socket achieves. The daemon would then receive no marker, see its own
// identity as the transport peer, and authorize the request as the daemon itself.
// An interceptor runs for every RPC whatever the request looked like.
func forwardIdentity(ctx context.Context) context.Context {
value, ok := ctx.Value(jsonPeerIdentity{}).(jsonPeerIdentityValue)
if !ok {
// No ConnContext ran for this request, so forward an unknown identity:
// the daemon must not mistake its own identity for the client's.
return ipcauth.WithForwardedIdentity(ctx, ipcauth.Identity{}, false)
}
return ipcauth.WithForwardedIdentity(ctx, value.id, value.known)
}
func forwardIdentityUnary(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
return invoker(forwardIdentity(ctx), method, req, reply, cc, opts...)
}
func forwardIdentityStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
return streamer(forwardIdentity(ctx), desc, cc, method, opts...)
}
// reservedHeaderWarning limits the dropped-header warning to the first occurrence.
var reservedHeaderWarning sync.Once
// jsonIncomingHeaderMatcher keeps an HTTP client from supplying the metadata the
// gateway uses to forward its identity. grpc-gateway turns "Grpc-Metadata-<key>"
// headers into gRPC metadata and joins them ahead of what its annotators add, so
// without this filter a JSON client could send its own x-netbird-fwd-uid and the
// daemon would authorize that instead of the client's real identity.
func jsonIncomingHeaderMatcher(key string) (string, bool) {
mapped, ok := runtime.DefaultHeaderMatcher(key)
if !ok {
return "", false
}
if ipcauth.IsReservedForwardKey(mapped) {
// Warn once: any client can send these on every request, so warning each
// time hands it a way to fill the log. The rest are debug-level.
reservedHeaderWarning.Do(func() {
log.Warnf("json gateway: dropping reserved header %q from a request: only the gateway may set the caller's identity", key)
})
log.Debugf("json gateway: dropping reserved header %q", key)
return "", false
}
return mapped, true
}
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("daemon JSON socket is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
"so privileged operations will be denied for JSON clients", jsonListener.address)
}
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
// grpc.NewClient does not connect until the first request, so registering
// the handler here cannot block daemon startup.
target, opts := daemonaddr.DialTarget(daemonEndpoint)
opts = append(opts,
grpc.WithChainUnaryInterceptor(forwardIdentityUnary),
grpc.WithChainStreamInterceptor(forwardIdentityStream),
)
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 {
if cerr := conn.Close(); cerr != nil {
log.Debugf("close daemon client after failed JSON gateway registration: %v", cerr)
}
return err
}
@@ -35,10 +131,12 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
BaseContext: func(net.Listener) context.Context {
return p.ctx
},
ConnContext: jsonConnContext,
}
p.jsonServMu.Lock()
p.jsonServ = jsonServer
p.jsonClient = conn
p.jsonServMu.Unlock()
go func() {

View File

@@ -0,0 +1,261 @@
//go:build !windows && !ios && !android
package cmd
import (
"context"
"net"
"net/http"
"path/filepath"
"testing"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// The JSON gateway runs inside the daemon and re-dials it locally, so every JSON
// request reaches a handler with the daemon's own identity as the transport peer.
// The gateway therefore forwards its HTTP client's identity as metadata, and the
// daemon authorizes that instead of itself. These tests drive the real wiring
// (jsonConnContext, forwardIdentity, jsonIncomingHeaderMatcher) and check the
// identity a handler would end up authorizing.
// daemonSideCtx is what a handler sees for a gateway-relayed call. The transport
// peer must be this process's own identity: the gateway is the daemon, so the two
// cannot differ, and hardcoding root here instead would describe a state that
// never occurs.
func daemonSideCtx(t *testing.T, md metadata.MD) context.Context {
t.Helper()
self, err := ipcauth.CurrentProcessIdentity()
if err != nil {
t.Skipf("cannot read this process's identity: %v", err)
}
ctx := peer.NewContext(context.Background(), &peer.Peer{
AuthInfo: ipcauth.AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: self,
},
})
return metadata.NewIncomingContext(ctx, md)
}
// gatewayMetadata reproduces what the daemon receives for a JSON request: the
// mux annotates the context from the request's headers, then the interceptor on the
// gateway's client connection stamps the caller's identity. The order matters,
// since the interceptor must win over anything a header put there.
func gatewayMetadata(t *testing.T, req *http.Request, ctx context.Context) metadata.MD {
t.Helper()
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
annotated, err := runtime.AnnotateContext(ctx, mux, req,
"/daemon.DaemonService/SetConfig",
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
if err != nil {
t.Fatalf("annotate: %v", err)
}
md, ok := metadata.FromOutgoingContext(forwardIdentity(annotated))
if !ok {
t.Fatal("the interceptor produced no metadata")
}
return md
}
// clientCtx is the connection context jsonConnContext would have produced for an
// HTTP client whose identity the gateway could read.
func clientCtx(id ipcauth.Identity, known bool) context.Context {
return context.WithValue(context.Background(), jsonPeerIdentity{},
jsonPeerIdentityValue{id: id, known: known})
}
// An HTTP client must not be able to name its own identity. grpc-gateway turns
// Grpc-Metadata-<key> headers into gRPC metadata, so without the header filter and
// the interceptor overwriting the reserved keys, this request would authorize as
// uid 0.
func TestJSONGateway_ForgedIdentityHeaderIsDropped(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Uid", "0")
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Gid", "0")
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd", "1")
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Sid", "S-1-5-18")
caller := ipcauth.Identity{UID: 31000, GID: 31000}
md := gatewayMetadata(t, req, clientCtx(caller, true))
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
if !ok {
t.Fatal("the forwarded identity should be usable")
}
if id.IsPrivileged() {
t.Errorf("forged header was believed: authorized as %v", id)
}
if id.UID != caller.UID {
t.Errorf("authorized as uid %d, want the real client %d", id.UID, caller.UID)
}
}
// A request with no headers at all (HTTP/1.0 needs no Host, and a unix socket
// yields no host:port) makes grpc-gateway produce no metadata whatsoever and skip
// its annotators: "if len(pairs) == 0 { return ctx, nil, nil }" in
// runtime/context.go. That is why the identity is stamped by an interceptor
// instead. This is the case that previously reached the gate as the daemon itself.
func TestJSONGateway_HeaderlessRequestIsStillMarkedForwarded(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
if err != nil {
t.Fatal(err)
}
req.Header = http.Header{}
req.Host = ""
caller := ipcauth.Identity{UID: 31000, GID: 31000}
ctx := clientCtx(caller, true)
// Pin the skip path itself: if grpc-gateway ever produced a pair here, this
// test would still pass below while no longer covering what it was written for.
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
annotated, err := runtime.AnnotateContext(ctx, mux, req,
"/daemon.DaemonService/SetConfig",
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
if err != nil {
t.Fatalf("annotate: %v", err)
}
if md, ok := metadata.FromOutgoingContext(annotated); ok {
t.Fatalf("grpc-gateway produced metadata %v for a headerless request; "+
"this test no longer covers the annotator-skip path", md)
}
md := gatewayMetadata(t, req, ctx)
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
if !ok {
t.Fatal("the forwarded identity should be usable")
}
if id.UID != caller.UID || id.IsPrivileged() {
t.Errorf("authorized as %v, want the real client uid %d", id, caller.UID)
}
}
// When the gateway cannot read its client's identity (a TCP JSON socket, say) it
// forwards the marker alone. The daemon must then report "unidentified" so the
// privileged operations refuse, rather than falling back to the gateway's own
// identity.
func TestJSONGateway_UnreadableClientIdentityIsUnidentified(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
if err != nil {
t.Fatal(err)
}
md := gatewayMetadata(t, req, clientCtx(ipcauth.Identity{}, false))
if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
t.Errorf("a request with no client identity was authorized as %v", id)
}
}
// A request that never passed through jsonConnContext (no stashed identity) must
// also come out unidentified rather than as the daemon.
func TestJSONGateway_MissingConnContextIsUnidentified(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
if err != nil {
t.Fatal(err)
}
md := gatewayMetadata(t, req, context.Background())
if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
t.Errorf("a request with no connection context was authorized as %v", id)
}
}
// End to end over a real unix socket: the gateway reads the connecting client's
// identity from the socket itself, so a client cannot present anything else.
func TestJSONGateway_IdentityComesFromTheSocket(t *testing.T) {
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
type observed struct {
md metadata.MD
}
seen := make(chan observed, 1)
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := runtime.AnnotateContext(r.Context(), mux, r,
"/daemon.DaemonService/SetConfig",
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
if err != nil {
t.Errorf("annotate: %v", err)
return
}
md, _ := metadata.FromOutgoingContext(forwardIdentity(ctx))
seen <- observed{md: md}
w.WriteHeader(http.StatusOK)
}),
ReadHeaderTimeout: 5 * time.Second,
ConnContext: jsonConnContext,
}
sock := filepath.Join(t.TempDir(), "http.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := srv.Close(); err != nil {
t.Logf("close server: %v", err)
}
})
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
t.Logf("serve: %v", err)
}
}()
conn, err := net.Dial("unix", sock)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := conn.Close(); err != nil {
t.Logf("close conn: %v", err)
}
})
// Forge the identity headers on the wire as well.
request := "POST /daemon.DaemonService/SetConfig HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Grpc-Metadata-X-Netbird-Fwd: 1\r\n" +
"Grpc-Metadata-X-Netbird-Fwd-Uid: 0\r\n" +
"Content-Length: 0\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
t.Fatal(err)
}
select {
case got := <-seen:
self, err := ipcauth.CurrentProcessIdentity()
if err != nil {
t.Skipf("cannot read this process's identity: %v", err)
}
// The socket peer is this test process, so that is the identity the
// gateway must forward, not the uid 0 the request asked for.
if uids := got.md.Get("x-netbird-fwd-uid"); len(uids) != 1 {
t.Fatalf("x-netbird-fwd-uid = %v, want exactly the gateway's own value", uids)
}
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, got.md))
if !ok {
t.Fatal("the forwarded identity should be usable")
}
if id.UID != self.UID {
t.Errorf("authorized as uid %d, want the socket peer %d", id.UID, self.UID)
}
case <-time.After(5 * time.Second):
t.Fatal("the gateway never handled the request")
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/configs"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/util"
)
@@ -125,6 +126,13 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
daemonAddr = params.DaemonAddr
// An install that predates named-pipe support has the loopback TCP
// address saved. Callers carry no identity over TCP, so move it to the
// pipe instead of restoring a socket the daemon cannot authorize on.
if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
cmd.Printf("Moving the saved daemon address from %s to %s so the daemon can identify its callers\n", daemonAddr, migrated)
daemonAddr = migrated
}
}
if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" {

View File

@@ -0,0 +1,14 @@
//go:build !windows
package cmd
import (
"fmt"
"net"
)
// listenNamedPipe is Windows-only: no other platform serves the daemon on a
// named pipe.
func listenNamedPipe(string) (net.Listener, string, error) {
return nil, "", fmt.Errorf("named pipes are only supported on Windows")
}

View File

@@ -0,0 +1,41 @@
//go:build windows
package cmd
import (
"errors"
"fmt"
"net"
"github.com/Microsoft/go-winio"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// listenNamedPipe creates the daemon control pipe and reports the path it ended
// up on. The security descriptor lets any local caller connect, as a Unix socket
// at 0666 does, and the privileged operations are authorized separately from the
// caller's token.
//
// The protected name comes first so that an unprivileged process cannot take the
// name before the service does. Creating it requires being an administrator or
// LocalSystem, so a daemon an ordinary user runs themselves, as in netstack mode,
// falls back to the plain name; clients try both and check who serves them.
func listenNamedPipe(name string) (net.Listener, string, error) {
var errs []error
for _, path := range daemonaddr.PipePaths(name) {
listener, err := winio.ListenPipe(path, &winio.PipeConfig{
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
})
if err != nil {
log.Debugf("not serving the daemon on %s: %v", path, err)
errs = append(errs, fmt.Errorf("%s: %w", path, err))
continue
}
return listener, path, nil
}
return nil, "", errors.Join(errs...)
}

View File

@@ -26,6 +26,14 @@ func listenOnAddress(addr string) (*socketListener, error) {
return nil, err
}
if network == "npipe" {
listener, path, err := listenNamedPipe(address)
if err != nil {
return nil, err
}
return &socketListener{Listener: listener, network: network, address: path}, nil
}
if network == "unix" {
removeStaleUnixSocket(address)
}
@@ -41,11 +49,11 @@ 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)

View File

@@ -325,7 +325,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
} else {
return fmt.Errorf("call service setConfig method: %v", err)
return daemonCallError("call service setConfig method", err)
}
}
@@ -379,7 +379,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
}
if loginErr != nil {
return fmt.Errorf("login failed: %v", loginErr)
return daemonCallError("login failed", loginErr)
}
if loginResp.NeedsSSOLogin {
@@ -392,7 +392,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
ProfileName: &profileID,
Username: &username,
}); err != nil {
return fmt.Errorf("call service up method: %v", err)
return daemonCallError("call service up method", err)
}
return nil
@@ -421,6 +421,12 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
if cmd.Flag(serverSSHAllowedFlag).Changed {
req.ServerSSHAllowed = &serverSSHAllowed
}
if cmd.Flag(serverVNCAllowedFlag).Changed {
req.ServerVNCAllowed = &serverVNCAllowed
}
if cmd.Flag(disableVNCApprovalFlag).Changed {
req.DisableVNCApproval = &disableVNCApproval
}
if cmd.Flag(enableSSHRootFlag).Changed {
req.EnableSSHRoot = &enableSSHRoot
}
@@ -523,30 +529,14 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
if cmd.Flag(serverSSHAllowedFlag).Changed {
ic.ServerSSHAllowed = &serverSSHAllowed
}
if cmd.Flag(enableSSHRootFlag).Changed {
ic.EnableSSHRoot = &enableSSHRoot
if cmd.Flag(serverVNCAllowedFlag).Changed {
ic.ServerVNCAllowed = &serverVNCAllowed
}
if cmd.Flag(disableVNCApprovalFlag).Changed {
ic.DisableVNCApproval = &disableVNCApproval
}
if cmd.Flag(enableSSHSFTPFlag).Changed {
ic.EnableSSHSFTP = &enableSSHSFTP
}
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
}
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
}
if cmd.Flag(disableSSHAuthFlag).Changed {
ic.DisableSSHAuth = &disableSSHAuth
}
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
ic.SSHJWTCacheTTL = &sshJWTCacheTTL
}
applySSHFlagsToConfig(cmd, &ic)
if cmd.Flag(interfaceNameFlag).Changed {
if err := parseInterfaceName(interfaceName); err != nil {
@@ -619,6 +609,49 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
return &ic, nil
}
func applySSHFlagsToConfig(cmd *cobra.Command, ic *profilemanager.ConfigInput) {
if cmd.Flag(enableSSHRootFlag).Changed {
ic.EnableSSHRoot = &enableSSHRoot
}
if cmd.Flag(enableSSHSFTPFlag).Changed {
ic.EnableSSHSFTP = &enableSSHSFTP
}
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
}
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
}
if cmd.Flag(disableSSHAuthFlag).Changed {
ic.DisableSSHAuth = &disableSSHAuth
}
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
ic.SSHJWTCacheTTL = &sshJWTCacheTTL
}
}
func applySSHFlagsToLogin(cmd *cobra.Command, req *proto.LoginRequest) {
if cmd.Flag(enableSSHRootFlag).Changed {
req.EnableSSHRoot = &enableSSHRoot
}
if cmd.Flag(enableSSHSFTPFlag).Changed {
req.EnableSSHSFTP = &enableSSHSFTP
}
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
}
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
}
if cmd.Flag(disableSSHAuthFlag).Changed {
req.DisableSSHAuth = &disableSSHAuth
}
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
ttl := int32(sshJWTCacheTTL)
req.SshJWTCacheTTL = &ttl
}
}
func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) {
loginRequest := proto.LoginRequest{
SetupKey: providedSetupKey,
@@ -648,31 +681,14 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
if cmd.Flag(serverSSHAllowedFlag).Changed {
loginRequest.ServerSSHAllowed = &serverSSHAllowed
}
if cmd.Flag(enableSSHRootFlag).Changed {
loginRequest.EnableSSHRoot = &enableSSHRoot
if cmd.Flag(serverVNCAllowedFlag).Changed {
loginRequest.ServerVNCAllowed = &serverVNCAllowed
}
if cmd.Flag(disableVNCApprovalFlag).Changed {
loginRequest.DisableVNCApproval = &disableVNCApproval
}
if cmd.Flag(enableSSHSFTPFlag).Changed {
loginRequest.EnableSSHSFTP = &enableSSHSFTP
}
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
}
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
}
if cmd.Flag(disableSSHAuthFlag).Changed {
loginRequest.DisableSSHAuth = &disableSSHAuth
}
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32
}
applySSHFlagsToLogin(cmd, &loginRequest)
if cmd.Flag(disableAutoConnectFlag).Changed {
loginRequest.DisableAutoConnect = &autoConnectDisabled

100
client/cmd/vnc_agent.go Normal file
View File

@@ -0,0 +1,100 @@
//go:build windows || (darwin && !ios)
package cmd
import (
"fmt"
"net"
"net/netip"
"os"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
vncserver "github.com/netbirdio/netbird/client/vnc/server"
)
var (
vncAgentSocket string
vncAgentTargetUID uint32
)
func init() {
vncAgentCmd.Flags().StringVar(&vncAgentSocket, "socket", "", "Unix-domain socket path the agent listens on (required)")
vncAgentCmd.Flags().Uint32Var(&vncAgentTargetUID, "target-uid", 0, "uid the agent should drop privileges to before listening (darwin only; 0 = stay as current uid)")
rootCmd.AddCommand(vncAgentCmd)
}
// vncAgentCmd runs a VNC server inside the user's interactive session,
// listening on a Unix-domain socket. The NetBird service spawns it: on
// Windows via CreateProcessAsUser into the console session, on macOS via
// launchctl asuser into the Aqua session.
var vncAgentCmd = &cobra.Command{
Use: "vnc-agent",
Short: "Run VNC capture agent (internal, spawned by service)",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
log.SetReportCaller(true)
log.SetFormatter(&log.JSONFormatter{})
log.SetOutput(os.Stderr)
if vncAgentSocket == "" {
return fmt.Errorf("--socket is required")
}
token := os.Getenv("NB_VNC_AGENT_TOKEN")
if token == "" {
return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service")
}
// Purge the token from env so it doesn't leak via /proc/<pid>/environ.
if err := os.Unsetenv("NB_VNC_AGENT_TOKEN"); err != nil {
log.Debugf("unset NB_VNC_AGENT_TOKEN: %v", err)
}
// Drop root privileges to the target console user BEFORE creating
// the listening socket: keeps a post-auth bug in the encoder /
// input / capture paths confined to the user's own privileges
// rather than escalating to host root, and makes the daemon's
// LOCAL_PEERCRED check see the right uid. No-op on Windows
// (both processes run as SYSTEM) and when --target-uid is 0.
if vncAgentTargetUID != 0 {
if err := dropAgentPrivileges(vncAgentTargetUID); err != nil {
return fmt.Errorf("drop privileges to uid %d: %w", vncAgentTargetUID, err)
}
}
if err := os.Remove(vncAgentSocket); err != nil && !os.IsNotExist(err) {
log.Debugf("remove stale socket %s: %v", vncAgentSocket, err)
}
ln, err := net.Listen("unix", vncAgentSocket)
if err != nil {
return fmt.Errorf("listen on %s: %w", vncAgentSocket, err)
}
if err := os.Chmod(vncAgentSocket, 0o600); err != nil {
log.Debugf("chmod %s: %v", vncAgentSocket, err)
}
capturer, injector, err := newAgentResources()
if err != nil {
_ = ln.Close()
return err
}
srv := vncserver.New(vncserver.Config{
Capturer: capturer,
Injector: injector,
DisableAuth: true,
AgentTokenHex: token,
Listener: ln,
})
if err := srv.Start(cmd.Context(), netip.AddrPort{}, netip.Prefix{}); err != nil {
return fmt.Errorf("start vnc server: %w", err)
}
log.Infof("vnc-agent listening on %s, ready", vncAgentSocket)
<-cmd.Context().Done()
log.Info("vnc-agent context cancelled, shutting down")
return srv.Stop()
},
SilenceUsage: true,
}

View File

@@ -0,0 +1,18 @@
//go:build darwin && !ios
package cmd
import (
"fmt"
vncserver "github.com/netbirdio/netbird/client/vnc/server"
)
func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
capturer := vncserver.NewMacPoller()
injector, err := vncserver.NewMacInputInjector()
if err != nil {
return nil, nil, fmt.Errorf("macOS input injector: %w", err)
}
return capturer, injector, nil
}

View File

@@ -0,0 +1,77 @@
//go:build darwin && !ios
package cmd
import (
"fmt"
"os"
"os/user"
"strconv"
"syscall"
)
// dropAgentPrivileges drops the vnc-agent process from root (its
// launchctl-asuser-inherited starting uid) to the target console user
// before any other initialisation runs. Without this the agent runs as
// root for the lifetime of the session; any post-auth memory-safety
// issue in the capture/input/encode paths would then be a root-level
// RCE on the host instead of a user-level one. Also makes the daemon's
// LOCAL_PEERCRED check correctly identify the agent as the console user,
// not as root.
//
// Returns an error when the agent is running as a non-root uid that
// differs from targetUID: non-root can only setuid to itself, so a
// mismatch here means the spawn went to the wrong session.
func dropAgentPrivileges(targetUID uint32) error {
if targetUID == 0 {
return fmt.Errorf("refusing to keep agent running as root (target uid 0)")
}
cur := uint32(os.Getuid())
if cur == targetUID {
return nil
}
if cur != 0 {
return fmt.Errorf("agent uid %d does not match expected %d and we lack root to fix it", cur, targetUID)
}
// Resolve the target user's real primary group rather than reusing
// targetUID as the gid: a user's primary group on macOS is typically
// staff(20), not gid==uid. Fail closed if the lookup fails.
targetGID, err := primaryGroupID(targetUID)
if err != nil {
return err
}
// Drop supplementary groups first: setgid alone doesn't touch the
// auxiliary group list, leaving root's groups attached would let the
// dropped process write to root-only group-writable files.
if err := syscall.Setgroups([]int{}); err != nil {
return fmt.Errorf("setgroups([]): %w", err)
}
if err := syscall.Setgid(targetGID); err != nil {
return fmt.Errorf("setgid(%d): %w", targetGID, err)
}
if os.Getgid() != targetGID || os.Getegid() != targetGID {
return fmt.Errorf("setgid verification: gid=%d egid=%d, expected %d", os.Getgid(), os.Getegid(), targetGID)
}
if err := syscall.Setuid(int(targetUID)); err != nil {
return fmt.Errorf("setuid(%d): %w", targetUID, err)
}
if uint32(os.Getuid()) != targetUID || uint32(os.Geteuid()) != targetUID {
return fmt.Errorf("setuid verification: uid=%d euid=%d, expected %d", os.Getuid(), os.Geteuid(), targetUID)
}
return nil
}
// primaryGroupID resolves the real primary group id of the user with the
// given uid. Fails closed: a lookup or parse error returns an error so the
// caller never falls back to using uid as the gid.
func primaryGroupID(targetUID uint32) (int, error) {
u, err := user.LookupId(strconv.Itoa(int(targetUID)))
if err != nil {
return 0, fmt.Errorf("look up uid %d: %w", targetUID, err)
}
gid, err := strconv.Atoi(u.Gid)
if err != nil {
return 0, fmt.Errorf("parse gid %q for uid %d: %w", u.Gid, targetUID, err)
}
return gid, nil
}

View File

@@ -0,0 +1,55 @@
//go:build darwin && !ios
package cmd
import (
"strings"
"testing"
)
// TestDropAgentPrivileges_RefusesRootTarget locks in the contract that
// dropAgentPrivileges must never be a no-op when asked to keep the
// agent as root (target uid 0). A future caller that passes 0 by
// mistake would otherwise leave the post-auth attack surface running
// with full root privileges.
func TestDropAgentPrivileges_RefusesRootTarget(t *testing.T) {
err := dropAgentPrivileges(0)
if err == nil {
t.Fatal("expected refusal for target uid 0, got nil")
}
if !strings.Contains(err.Error(), "root") {
t.Fatalf("error should mention root, got: %v", err)
}
}
// TestDropAgentPrivileges_NoOpWhenAlreadyTarget covers the dev path
// where the agent is launched by hand as the target user (no root
// available, no setuid needed). The helper must succeed silently
// instead of trying (and failing) a setuid to its current uid.
func TestDropAgentPrivileges_NoOpWhenAlreadyTarget(t *testing.T) {
// Skip when running as root: the early-return path we want to
// cover only fires when current uid == target uid.
uid := currentUIDForTest()
if uid == 0 {
t.Skip("test must not run as root; cannot exercise the no-op early-return")
}
if err := dropAgentPrivileges(uid); err != nil {
t.Fatalf("expected no-op when current uid == target, got: %v", err)
}
}
// TestDropAgentPrivileges_RefusesMismatchedNonRoot guards the "non-root
// caller tries to setuid to a different uid" path: setuid would fail
// with EPERM anyway, but the helper should surface a clear error
// before issuing the syscall so a misconfigured spawn (wrong --target-uid
// flag) is debuggable.
func TestDropAgentPrivileges_RefusesMismatchedNonRoot(t *testing.T) {
uid := currentUIDForTest()
if uid == 0 {
t.Skip("test must not run as root; covered case requires non-root caller")
}
err := dropAgentPrivileges(uid + 1)
if err == nil {
t.Fatal("expected refusal when non-root caller asks to setuid elsewhere")
}
}

View File

@@ -0,0 +1,11 @@
//go:build darwin && !ios
package cmd
import "os"
// currentUIDForTest exposes os.Getuid for the darwin dropprivs tests
// without leaking an os import into the test file itself.
func currentUIDForTest() uint32 {
return uint32(os.Getuid())
}

View File

@@ -0,0 +1,14 @@
//go:build windows
package cmd
// dropAgentPrivileges is a no-op on Windows: the agent and the daemon
// both run as SYSTEM (the daemon spawns the agent into the interactive
// session via CreateProcessAsUser with an impersonation token, but the
// resulting process still runs under SYSTEM, not under the user's
// account). The Windows path relies on the DACL-restricted socket
// directory, the unpredictable per-spawn socket name, the listen-readiness
// gate, and the per-spawn token for integrity instead.
func dropAgentPrivileges(_ uint32) error {
return nil
}

View File

@@ -0,0 +1,15 @@
//go:build windows
package cmd
import (
log "github.com/sirupsen/logrus"
vncserver "github.com/netbirdio/netbird/client/vnc/server"
)
func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
sessionID := vncserver.GetCurrentSessionID()
log.Infof("VNC agent running in Windows session %d", sessionID)
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), nil
}

16
client/cmd/vnc_flags.go Normal file
View File

@@ -0,0 +1,16 @@
package cmd
const (
serverVNCAllowedFlag = "allow-server-vnc"
disableVNCApprovalFlag = "disable-vnc-approval"
)
var (
serverVNCAllowed bool
disableVNCApproval bool
)
func init() {
upCmd.PersistentFlags().BoolVar(&serverVNCAllowed, serverVNCAllowedFlag, false, "Allow embedded VNC server on peer")
upCmd.PersistentFlags().BoolVar(&disableVNCApproval, disableVNCApprovalFlag, false, "Disable per-connection user approval prompts for the embedded VNC server")
}

View File

@@ -6,19 +6,35 @@ import (
"runtime"
)
var StateDir string
// UILogFile is the file name the desktop UI writes its log to. It is defined
// here so the UI (writer), the daemon's RegisterUILog validation, and the debug
// bundle collector all share one definition.
const UILogFile = "gui-client.log"
var (
// StateDir holds persistent state (config, profiles, install metadata).
StateDir string
// RuntimeDir holds ephemeral artifacts that should not survive reboot,
// such as Unix sockets for daemon and per-session IPC. Empty on
// platforms without a conventional /var/run-style location.
RuntimeDir string
)
func init() {
StateDir = os.Getenv("NB_STATE_DIR")
if StateDir != "" {
return
}
switch runtime.GOOS {
case "windows":
StateDir = filepath.Join(os.Getenv("PROGRAMDATA"), "Netbird")
case "darwin", "linux":
StateDir = "/var/lib/netbird"
RuntimeDir = "/var/run/netbird"
case "freebsd", "openbsd", "netbsd", "dragonfly":
StateDir = "/var/db/netbird"
RuntimeDir = "/var/run/netbird"
}
if v := os.Getenv("NB_STATE_DIR"); v != "" {
StateDir = v
}
if v := os.Getenv("NB_RUNTIME_DIR"); v != "" {
RuntimeDir = v
}
}

View File

@@ -0,0 +1,60 @@
package nftables
import (
"testing"
"github.com/google/nftables/expr"
"github.com/stretchr/testify/require"
)
func TestBuildLegacyRouteRuleExpressions(t *testing.T) {
sourcePayload := &expr.Payload{}
sourceCmp := &expr.Cmp{}
destinationPayload := &expr.Payload{}
destinationCmp := &expr.Cmp{}
nilSourceDestination := &expr.Payload{}
nilDestinationSource := &expr.Cmp{}
tests := []struct {
name string
source []expr.Any
destination []expr.Any
matches []expr.Any
}{
{
name: "both non-empty",
source: []expr.Any{sourcePayload, sourceCmp},
destination: []expr.Any{destinationPayload, destinationCmp},
matches: []expr.Any{sourcePayload, sourceCmp, destinationPayload, destinationCmp},
},
{
name: "nil source",
destination: []expr.Any{nilSourceDestination},
matches: []expr.Any{nilSourceDestination},
},
{
name: "nil destination",
source: []expr.Any{nilDestinationSource},
matches: []expr.Any{nilDestinationSource},
},
{
name: "both nil",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildLegacyRouteRuleExpressions(tt.source, tt.destination)
require.Len(t, got, len(tt.matches)+2)
for i, match := range tt.matches {
require.Same(t, match, got[i])
}
require.IsType(t, &expr.Counter{}, got[len(tt.matches)])
verdict, ok := got[len(tt.matches)+1].(*expr.Verdict)
require.True(t, ok)
require.Equal(t, expr.VerdictAccept, verdict.Kind)
})
}
}

View File

@@ -953,6 +953,17 @@ func (r *router) addMSSClampingRules() error {
return r.conn.Flush()
}
func buildLegacyRouteRuleExpressions(sourceExp, destExp []expr.Any) []expr.Any {
exprs := make([]expr.Any, 0, len(sourceExp)+len(destExp)+2)
exprs = append(exprs, sourceExp...)
exprs = append(exprs, destExp...)
exprs = append(exprs,
&expr.Counter{},
&expr.Verdict{Kind: expr.VerdictAccept},
)
return exprs
}
// addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls
func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error {
sourceExp, err := r.applyNetwork(pair.Source, nil, true)
@@ -965,15 +976,7 @@ func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error {
return fmt.Errorf("apply destination: %w", err)
}
exprs := []expr.Any{
&expr.Counter{},
&expr.Verdict{
Kind: expr.VerdictAccept,
},
}
exprs = append(exprs, sourceExp...)
exprs = append(exprs, destExp...)
exprs := buildLegacyRouteRuleExpressions(sourceExp, destExp)
ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair)

View File

@@ -0,0 +1,219 @@
// Package approval brokers per-attempt user-accept prompts for inbound
// remote access (VNC today, SSH and others in the future). A caller pushes
// a Prompt; the broker emits a SystemEvent on the daemon→UI stream and
// blocks until the UI calls the daemon's RespondApproval RPC, the per-
// request timeout fires, or no subscriber is connected. The latter case
// fails closed so a backgrounded UI cannot silently bypass the gate.
package approval
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
// Metadata keys the broker reserves on the emitted SystemEvent. Callers
// should not set these themselves; values in Prompt.Metadata that collide
// are overwritten by the broker.
const (
MetaRequestID = "request_id"
MetaKind = "kind"
MetaExpiresAt = "expires_at"
)
// ShortKeyFingerprint formats a hex-encoded Noise_IK static pubkey as a
// short, eyeball-able fingerprint to display in the approval dialog.
// The dashboard-supplied display name attached to a SessionPubKey isn't
// cryptographically asserted by the connecting client, so the prompt
// must also show something that IS: the key fingerprint, a hash of
// the static public key the client just proved possession of during the
// Noise handshake. Returns the empty string when the input is too short
// to plausibly be a hex pubkey, so the row is omitted rather than
// rendered as a misleading partial.
//
// Output format: 16 hex chars grouped as XXXX-XXXX-XXXX-XXXX (64 bits of
// fingerprint, resistant to random-prefix collisions and easy for a human
// to compare with an out-of-band reference).
func ShortKeyFingerprint(hexKey string) string {
if len(hexKey) < 8 {
return ""
}
src := hexKey
if len(src) > 16 {
src = src[:16]
}
var out []byte
for i, c := range src {
if i > 0 && i%4 == 0 {
out = append(out, '-')
}
out = append(out, byte(c))
}
return string(out)
}
// Kind values for the well-known prompt subjects. New subsystems should
// add a constant here so the UI can dispatch on a known string.
const (
KindVNC = "vnc"
KindSSH = "ssh"
)
// DefaultTimeout is the wall-clock window the user has to accept or deny a
// pending approval before the broker fails closed and returns ErrTimeout.
// Kept well under typical VNC client and dashboard connection timeouts so
// the RFB rejection actually reaches the browser instead of racing the
// browser's own "connection timed out" message.
const DefaultTimeout = 15 * time.Second
// timeoutValue returns the active timeout. It's a var so tests in this
// package can shorten the wait without exposing a setter on the public
// API. Production code always sees DefaultTimeout.
var timeoutValue = func() time.Duration { return DefaultTimeout }
// ErrNoSubscriber indicates no UI is connected to consume the prompt.
// The caller must reject the underlying connection (fail-closed).
var ErrNoSubscriber = errors.New("no UI subscriber connected for approval")
// ErrTimeout indicates the user did not respond within DefaultTimeout.
var ErrTimeout = errors.New("approval timed out")
// ErrDenied indicates the user explicitly denied the connection.
var ErrDenied = errors.New("approval denied")
// EventPublisher is the subset of peer.Status used to emit prompts.
type EventPublisher interface {
PublishEvent(
severity proto.SystemEvent_Severity,
category proto.SystemEvent_Category,
msg string,
userMsg string,
metadata map[string]string,
)
HasEventSubscribers() bool
}
// Prompt describes the pending request shown to the user. Kind selects
// the UI dispatch path (e.g. "vnc", "ssh"). Subject is the human-readable
// one-liner the UI may show as a title or notification body. Metadata is
// passed through verbatim and is the subsystem-specific payload (peer
// name, source IP, mode, etc.).
type Prompt struct {
Kind string
Subject string
Metadata map[string]string
}
// Decision carries the user's response to an approval prompt. ViewOnly is
// only meaningful when Accept is true; it lets the host grant the
// connection but signal the requester that input control is withheld.
type Decision struct {
Accept bool
ViewOnly bool
}
// Broker holds in-flight approval requests keyed by request ID.
type Broker struct {
pub EventPublisher
mu sync.Mutex
pending map[string]chan Decision
}
// New returns a broker that publishes prompts via pub.
func New(pub EventPublisher) *Broker {
return &Broker{
pub: pub,
pending: make(map[string]chan Decision),
}
}
// Request emits a SystemEvent for p and blocks until the UI calls Respond,
// ctx is cancelled, or DefaultTimeout elapses. Returns a Decision when
// the user replied; ErrDenied / ErrTimeout / ErrNoSubscriber / ctx.Err
// otherwise. Callers must treat any non-nil error as a deny.
func (b *Broker) Request(ctx context.Context, p Prompt) (Decision, error) {
var zero Decision
if b == nil || b.pub == nil {
return zero, fmt.Errorf("approval broker not configured")
}
if !b.pub.HasEventSubscribers() {
return zero, ErrNoSubscriber
}
id := uuid.NewString()
resp := make(chan Decision, 1)
b.mu.Lock()
b.pending[id] = resp
b.mu.Unlock()
defer b.dropPending(id)
timeout := timeoutValue()
expiresAt := time.Now().Add(timeout)
meta := make(map[string]string, len(p.Metadata)+3)
for k, v := range p.Metadata {
meta[k] = v
}
meta[MetaRequestID] = id
meta[MetaKind] = p.Kind
meta[MetaExpiresAt] = expiresAt.UTC().Format(time.RFC3339)
subject := p.Subject
if subject == "" {
subject = fmt.Sprintf("%s connection requires approval", p.Kind)
}
b.pub.PublishEvent(proto.SystemEvent_INFO, proto.SystemEvent_APPROVAL, subject, subject, meta)
log.Debugf("approval request %s (%s) emitted: %s", id, p.Kind, subject)
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case d := <-resp:
if !d.Accept {
return zero, ErrDenied
}
return d, nil
case <-timer.C:
return zero, ErrTimeout
case <-ctx.Done():
return zero, ctx.Err()
}
}
// Respond delivers the user's decision for id. Returns true when a pending
// request matched and was woken, false when id was unknown or already done.
func (b *Broker) Respond(id string, d Decision) bool {
if b == nil {
return false
}
b.mu.Lock()
ch, ok := b.pending[id]
if ok {
delete(b.pending, id)
}
b.mu.Unlock()
if !ok {
return false
}
select {
case ch <- d:
default:
}
return true
}
func (b *Broker) dropPending(id string) {
b.mu.Lock()
delete(b.pending, id)
b.mu.Unlock()
}

View File

@@ -0,0 +1,434 @@
package approval
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
)
// fakePublisher records published events and reports whether subscribers
// are connected. The subscribers flag is the security-critical signal:
// when false the broker must refuse to emit and the gate must fail closed.
type fakePublisher struct {
mu sync.Mutex
subscribers bool
events []*proto.SystemEvent
}
func (p *fakePublisher) PublishEvent(
severity proto.SystemEvent_Severity,
category proto.SystemEvent_Category,
msg string,
userMsg string,
metadata map[string]string,
) {
p.mu.Lock()
p.events = append(p.events, &proto.SystemEvent{
Severity: severity,
Category: category,
Message: msg,
UserMessage: userMsg,
Metadata: metadata,
})
p.mu.Unlock()
}
func (p *fakePublisher) HasEventSubscribers() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.subscribers
}
func (p *fakePublisher) lastEvent(t *testing.T) *proto.SystemEvent {
t.Helper()
p.mu.Lock()
defer p.mu.Unlock()
require.NotEmpty(t, p.events, "publisher saw no events")
return p.events[len(p.events)-1]
}
func (p *fakePublisher) eventCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.events)
}
// TestRequestNoSubscriberFailsClosed is the core fail-closed invariant:
// when the UI is not subscribed, the broker must refuse without emitting
// an event or arming a waiter. A regression here is a silent bypass.
func TestRequestNoSubscriberFailsClosed(t *testing.T) {
pub := &fakePublisher{subscribers: false}
b := New(pub)
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
assert.ErrorIs(t, err, ErrNoSubscriber)
assert.Equal(t, 0, pub.eventCount(), "no event must be emitted when fail-closed")
b.mu.Lock()
pending := len(b.pending)
b.mu.Unlock()
assert.Equal(t, 0, pending, "no waiter must be registered on fail-closed")
}
// TestRequestTimeoutDenies verifies that a request without a UI response
// returns ErrTimeout (deny) rather than nil (silent accept). Uses a short
// per-test broker timeout via Respond after the fact to keep the test fast.
func TestRequestTimeoutDenies(t *testing.T) {
// Replace DefaultTimeout for the lifetime of this test.
orig := DefaultTimeout
defaultTimeout(t, 60*time.Millisecond)
defer defaultTimeout(t, orig)
pub := &fakePublisher{subscribers: true}
b := New(pub)
start := time.Now()
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
assert.ErrorIs(t, err, ErrTimeout, "missing user response must yield ErrTimeout, not nil")
assert.GreaterOrEqual(t, time.Since(start), 50*time.Millisecond, "timeout fired prematurely")
}
// TestRequestDenied returns ErrDenied when the UI responds with false.
func TestRequestDenied(t *testing.T) {
pub := &fakePublisher{subscribers: true}
b := New(pub)
var requestID string
done := make(chan error, 1)
go func() {
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
}()
requestID = waitForRequestID(t, pub)
require.True(t, b.Respond(requestID, Decision{Accept: false}))
select {
case err := <-done:
assert.ErrorIs(t, err, ErrDenied)
case <-time.After(time.Second):
t.Fatal("Request did not return after Respond(false)")
}
}
// TestRequestAccepted is the happy path. Failure here doesn't bypass the
// gate but breaks the feature.
func TestRequestAccepted(t *testing.T) {
pub := &fakePublisher{subscribers: true}
b := New(pub)
done := make(chan error, 1)
go func() {
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC, Subject: "test"})
}()
id := waitForRequestID(t, pub)
require.True(t, b.Respond(id, Decision{Accept: true}))
select {
case err := <-done:
assert.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("Request did not return after Respond(true)")
}
}
// TestRequestCtxCancelDenies verifies that an upstream cancel (e.g. the
// engine shutting down mid-prompt) returns the cancel error rather than
// nil. A nil here would be a silent bypass on shutdown races.
func TestRequestCtxCancelDenies(t *testing.T) {
pub := &fakePublisher{subscribers: true}
b := New(pub)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- requestErr(b, ctx, Prompt{Kind: KindVNC, Subject: "test"})
}()
// Wait until the prompt is in flight so cancel races a live waiter.
_ = waitForRequestID(t, pub)
cancel()
select {
case err := <-done:
assert.ErrorIs(t, err, context.Canceled)
case <-time.After(time.Second):
t.Fatal("Request did not return after ctx cancel")
}
}
// TestRespondUnknownIsNoop ensures a stray RespondApproval RPC cannot
// affect or accidentally accept any in-flight request whose id it doesn't
// match. Also confirms it doesn't panic.
func TestRespondUnknownIsNoop(t *testing.T) {
pub := &fakePublisher{subscribers: true}
b := New(pub)
// No in-flight prompts: Respond returns false.
assert.False(t, b.Respond("does-not-exist", Decision{Accept: true}))
// With an in-flight prompt, a wrong id still returns false and the
// prompt remains armed (eventually timing out as a deny).
defaultTimeout(t, 60*time.Millisecond)
defer defaultTimeout(t, DefaultTimeout)
done := make(chan error, 1)
go func() {
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
}()
realID := waitForRequestID(t, pub)
assert.False(t, b.Respond("totally-bogus", Decision{Accept: true}), "unknown id must not match")
assert.NotEqual(t, "totally-bogus", realID)
select {
case err := <-done:
assert.ErrorIs(t, err, ErrTimeout, "armed prompt must still time out, not accept")
case <-time.After(time.Second):
t.Fatal("prompt did not resolve")
}
}
// TestRespondAfterTimeoutNoop confirms a late accept response can't
// retroactively flip a denied (timed-out) request. The dropPending defer
// in Request must have removed the entry by the time Respond races in.
func TestRespondAfterTimeoutNoop(t *testing.T) {
defaultTimeout(t, 30*time.Millisecond)
defer defaultTimeout(t, DefaultTimeout)
pub := &fakePublisher{subscribers: true}
b := New(pub)
done := make(chan error, 1)
go func() {
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
}()
id := waitForRequestID(t, pub)
select {
case err := <-done:
require.ErrorIs(t, err, ErrTimeout)
case <-time.After(time.Second):
t.Fatal("prompt did not time out")
}
assert.False(t, b.Respond(id, Decision{Accept: true}), "late respond must be no-op")
}
// TestRespondDoubleNoop ensures a duplicate ack from the UI doesn't leak
// past the matched waiter or panic on a closed/full channel.
func TestRespondDoubleNoop(t *testing.T) {
pub := &fakePublisher{subscribers: true}
b := New(pub)
done := make(chan error, 1)
go func() {
done <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
}()
id := waitForRequestID(t, pub)
require.True(t, b.Respond(id, Decision{Accept: true}))
assert.False(t, b.Respond(id, Decision{Accept: false}), "second response must be no-op")
select {
case err := <-done:
assert.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("prompt did not resolve")
}
}
// TestNilBrokerRequestErrors guards the engine pre-init path where the
// broker may not yet exist (or its publisher is nil): Request must
// error, never silently accept.
func TestNilBrokerRequestErrors(t *testing.T) {
var b *Broker
_, err := b.Request(context.Background(), Prompt{Kind: KindVNC})
assert.Error(t, err, "nil broker must error, never silently accept")
b2 := New(nil)
_, err = b2.Request(context.Background(), Prompt{Kind: KindVNC})
assert.Error(t, err, "broker with nil publisher must error, never silently accept")
}
// TestPromptMetadataInjected confirms the broker stamps request_id, kind,
// and expires_at on the emitted event. The UI relies on these keys; if
// they are dropped, the user cannot route the prompt and the response
// path breaks (which fails closed via timeout).
func TestPromptMetadataInjected(t *testing.T) {
pub := &fakePublisher{subscribers: true}
b := New(pub)
done := make(chan error, 1)
go func() {
done <- requestErr(b, context.Background(), Prompt{
Kind: KindVNC,
Subject: "VNC connection from peerA",
Metadata: map[string]string{"peer_name": "peerA"},
})
}()
id := waitForRequestID(t, pub)
ev := pub.lastEvent(t)
assert.Equal(t, proto.SystemEvent_APPROVAL, ev.Category)
assert.Equal(t, KindVNC, ev.Metadata[MetaKind])
assert.Equal(t, id, ev.Metadata[MetaRequestID])
assert.NotEmpty(t, ev.Metadata[MetaExpiresAt])
assert.Equal(t, "peerA", ev.Metadata["peer_name"], "caller metadata must pass through")
require.True(t, b.Respond(id, Decision{Accept: true}))
<-done
}
// TestConcurrentRequests verifies that two concurrent prompts are tracked
// independently. A bug that aliases ids would let one Respond unblock
// the wrong waiter (a silent accept across prompts).
func TestConcurrentRequests(t *testing.T) {
pub := &fakePublisher{subscribers: true}
b := New(pub)
const n = 20
results := make(chan error, n)
for i := 0; i < n; i++ {
go func() {
results <- requestErr(b, context.Background(), Prompt{Kind: KindVNC})
}()
}
ids := waitForNRequestIDs(t, pub, n)
require.Len(t, ids, n)
// Deny exactly half, accept the rest. Track outcome per id so we can
// match each Request's return value against the response we sent.
denySet := make(map[string]bool, n)
for i, id := range ids {
deny := i%2 == 0
denySet[id] = deny
require.True(t, b.Respond(id, Decision{Accept: !deny}))
}
// Collect all returns and check no nil errors slipped past a deny.
var accepted, denied atomic.Int32
for i := 0; i < n; i++ {
select {
case err := <-results:
if err == nil {
accepted.Add(1)
} else {
assert.ErrorIs(t, err, ErrDenied)
denied.Add(1)
}
case <-time.After(2 * time.Second):
t.Fatalf("only got %d/%d responses", i, n)
}
}
assert.Equal(t, int32(n/2), denied.Load())
assert.Equal(t, int32(n/2), accepted.Load())
}
// waitForRequestID blocks until the publisher sees its next event and
// returns the request_id stamped on it.
func waitForRequestID(t *testing.T, pub *fakePublisher) string {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
pub.mu.Lock()
count := len(pub.events)
var id string
if count > 0 {
id = pub.events[count-1].Metadata[MetaRequestID]
}
pub.mu.Unlock()
if id != "" {
return id
}
time.Sleep(2 * time.Millisecond)
}
t.Fatal("timeout waiting for emitted event")
return ""
}
func waitForNRequestIDs(t *testing.T, pub *fakePublisher, n int) []string {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
pub.mu.Lock()
count := len(pub.events)
pub.mu.Unlock()
if count >= n {
break
}
time.Sleep(2 * time.Millisecond)
}
pub.mu.Lock()
defer pub.mu.Unlock()
out := make([]string, 0, len(pub.events))
seen := make(map[string]struct{}, len(pub.events))
for _, ev := range pub.events {
id := ev.Metadata[MetaRequestID]
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
if len(out) < n {
t.Fatalf("only got %d/%d request ids", len(out), n)
}
return out
}
// defaultTimeout swaps the broker's per-request wall-clock window so the
// timeout tests run quickly. Restores the prior value on the next call.
func defaultTimeout(t *testing.T, d time.Duration) {
t.Helper()
if d <= 0 {
t.Fatal("defaultTimeout must be > 0")
}
timeoutValue = func() time.Duration { return d }
}
// requestErr wraps Broker.Request to drop the Decision when tests only
// care about the error path. Keeps the goroutine bodies tight.
func requestErr(b *Broker, ctx context.Context, p Prompt) error {
_, err := b.Request(ctx, p)
return err
}
// TestRequestViewOnly checks the view-only outcome flows through Request's
// Decision return without being silently swallowed.
func TestRequestViewOnly(t *testing.T) {
pub := &fakePublisher{subscribers: true}
b := New(pub)
type result struct {
d Decision
err error
}
done := make(chan result, 1)
go func() {
d, err := b.Request(context.Background(), Prompt{Kind: KindVNC})
done <- result{d, err}
}()
id := waitForRequestID(t, pub)
require.True(t, b.Respond(id, Decision{Accept: true, ViewOnly: true}))
select {
case r := <-done:
assert.NoError(t, r.err)
assert.True(t, r.d.Accept)
assert.True(t, r.d.ViewOnly, "ViewOnly must survive the round-trip")
case <-time.After(time.Second):
t.Fatal("view-only request did not resolve")
}
}

View File

@@ -0,0 +1,62 @@
package approval
import "testing"
// TestShortKeyFingerprint locks in the format the VNC approval prompt
// shows to the user. The fingerprint is the user's only cryptographic
// anchor against a malicious management server that pushes a spoofed
// display name, so accidental changes to its format would silently
// undermine that defence.
func TestShortKeyFingerprint(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "full_32_byte_pubkey",
in: "0123456789abcdeffedcba9876543210ffeeddccbbaa99887766554433221100",
want: "0123-4567-89ab-cdef",
},
{
name: "exactly_16_chars",
in: "0123456789abcdef",
want: "0123-4567-89ab-cdef",
},
{
name: "borderline_8_chars",
in: "01234567",
want: "0123-4567",
},
{
name: "too_short_returns_empty",
in: "0123",
want: "",
},
{
name: "empty_returns_empty",
in: "",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ShortKeyFingerprint(tc.in)
if got != tc.want {
t.Fatalf("ShortKeyFingerprint(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestShortKeyFingerprint_DistinctKeysDistinctOutputs guards against a
// formatting bug that would collapse different prefixes onto the same
// displayed fingerprint and let an attacker substitute their pubkey for
// a victim's while keeping the prompt visually identical.
func TestShortKeyFingerprint_DistinctKeysDistinctOutputs(t *testing.T) {
a := ShortKeyFingerprint("0123456789abcdef" + "rest_of_pubkey_ignored")
b := ShortKeyFingerprint("0123456789abcde0" + "rest_of_pubkey_ignored")
if a == b {
t.Fatalf("expected distinct outputs for distinct prefixes, both = %q", a)
}
}

View File

@@ -344,6 +344,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
a.config.RosenpassEnabled,
a.config.RosenpassPermissive,
a.config.ServerSSHAllowed,
a.config.ServerVNCAllowed,
a.config.DisableClientRoutes,
a.config.DisableServerRoutes,
a.config.DisableDNS,

View File

@@ -611,6 +611,8 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
RosenpassEnabled: config.RosenpassEnabled,
RosenpassPermissive: config.RosenpassPermissive,
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
ServerVNCAllowed: config.ServerVNCAllowed != nil && *config.ServerVNCAllowed,
DisableVNCApproval: config.DisableVNCApproval,
EnableSSHRoot: config.EnableSSHRoot,
EnableSSHSFTP: config.EnableSSHSFTP,
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
@@ -694,6 +696,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
config.RosenpassEnabled,
config.RosenpassPermissive,
config.ServerSSHAllowed,
config.ServerVNCAllowed,
config.DisableClientRoutes,
config.DisableServerRoutes,
config.DisableDNS,

View File

@@ -0,0 +1,15 @@
package daemonaddr
// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very
// user. That is what makes an unprivileged daemon authorize this process for the
// changes it otherwise restricts to root or an administrator, so a client can tell
// up front whether those controls are usable instead of letting a save fail.
//
// It is answered from the ownership of the socket or pipe the daemon created, so it
// costs no round trip and needs no cooperation from the daemon. Ownership that
// cannot be read is reported as false, including for a TCP address, so a caller
// reading this as "the daemon would allow it" fails closed. The daemon remains the
// only thing that authorizes anything: this only decides what a client offers.
func DaemonRunsAsSelf(addr string) bool {
return daemonRunsAsSelf(addr)
}

View File

@@ -0,0 +1,40 @@
//go:build !windows
package daemonaddr
import (
"os"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
)
// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this
// process's uid. Root is not treated specially here: a root caller is privileged
// on its own merits, and a root-owned socket says nothing about the caller.
func daemonRunsAsSelf(addr string) bool {
path, ok := strings.CutPrefix(addr, "unix://")
if !ok {
return false
}
info, err := os.Stat(path)
if err != nil {
log.Debugf("stat daemon socket %s: %v", path, err)
return false
}
// Only a socket says anything about a daemon. A directory or a leftover
// regular file at that path is not one, and reading it as "the daemon runs as
// us" would offer controls the daemon then refuses.
if info.Mode()&os.ModeSocket == 0 {
return false
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return false
}
return stat.Uid == uint32(os.Getuid())
}

View File

@@ -0,0 +1,62 @@
//go:build !windows
package daemonaddr
import (
"net"
"os"
"path/filepath"
"testing"
)
// A socket this user created means the daemon runs as this user, which is the
// rootless case where the daemon delegates its authority to its own identity.
func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) {
path := filepath.Join(t.TempDir(), "netbird.sock")
ln, err := net.Listen("unix", path)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() {
if err := ln.Close(); err != nil {
t.Logf("close listener: %v", err)
}
})
if !DaemonRunsAsSelf("unix://" + path) {
t.Error("a socket owned by this user must count as the daemon running as us")
}
}
// Everything that is not a readable socket of ours has to answer false, because
// the caller reads a true as "the daemon would authorize me".
func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) {
dir := t.TempDir()
// A socket owned by another user, which is what a root-run daemon looks like
// to an unprivileged client. Only assertable when we are not root ourselves.
rootOwned := "unix:///var/run/netbird.sock"
if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 {
if DaemonRunsAsSelf(rootOwned) {
t.Error("a socket owned by another user must not count as ours")
}
}
for name, addr := range map[string]string{
"missing socket": "unix://" + filepath.Join(dir, "absent.sock"),
"tcp address": "tcp://127.0.0.1:41731",
"named pipe": "npipe://netbird",
"empty": "",
"no scheme": filepath.Join(dir, "absent.sock"),
"directory": "unix://" + dir,
"unknown scheme": "http://localhost:8080",
"scheme only": "unix://",
"relative socket": "unix://netbird.sock",
} {
t.Run(name, func(t *testing.T) {
if DaemonRunsAsSelf(addr) {
t.Errorf("%q must not count as a daemon running as us", addr)
}
})
}
}

View File

@@ -0,0 +1,42 @@
//go:build windows
package daemonaddr
import (
"context"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the
// service account owns its pipe as LocalSystem, and an elevated one as
// BUILTIN\Administrators, so only a daemon the user started themselves matches.
func daemonRunsAsSelf(addr string) bool {
name, ok := strings.CutPrefix(addr, pipeScheme)
if !ok {
return false
}
for _, path := range PipePaths(name) {
// Bounded: this runs on the UI's path for deciding which controls to
// offer, so a pipe that does not answer promptly must not stall it. A
// timeout leaves the caller unprivileged, which only disables controls.
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
conn, err := dialPipe(ctx, path)
cancel()
if err != nil {
continue
}
owned := ipcauth.PipeOwnedBySelf(conn)
if cerr := conn.Close(); cerr != nil {
log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr)
}
return owned
}
return false
}

View File

@@ -0,0 +1,103 @@
package daemonaddr
import (
"context"
"net"
"runtime"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
// WindowsPipeAddr is the default daemon address on Windows. A named pipe
// carries the connecting process's token, which loopback TCP does not, so
// it is the only Windows transport on which the daemon can tell who is
// calling it.
WindowsPipeAddr = "npipe://netbird"
// legacyWindowsAddr is the loopback-TCP address the Windows daemon used
// before named-pipe support.
legacyWindowsAddr = "tcp://127.0.0.1:41731"
pipeScheme = "npipe://"
// protectedPrefix is the NPFS namespace in which only LocalSystem and
// members of BUILTIN\Administrators may create a pipe. A daemon running as
// the service account creates its pipe there so that an unprivileged process
// cannot pre-create the name, which would keep the daemon from starting and
// leave callers talking to the squatter. Opening such a pipe needs no
// privilege, so unprivileged clients still reach the daemon.
protectedPrefix = `ProtectedPrefix\Administrators\`
)
// DialTarget returns the gRPC dial target and transport options for a daemon
// address. The npipe scheme needs a context dialer because gRPC has no
// named-pipe resolver; unix and tcp are handled by gRPC itself.
func DialTarget(addr string) (string, []grpc.DialOption) {
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
if name, ok := strings.CutPrefix(addr, pipeScheme); ok {
paths := PipePaths(name)
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return dialPipePaths(ctx, paths)
}))
return "passthrough:///netbird-daemon-pipe", opts
}
return strings.TrimPrefix(addr, "tcp://"), opts
}
// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a
// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as
// is.
func PipePath(name string) string {
if strings.HasPrefix(name, `\\`) {
return name
}
return `\\.\pipe\` + name
}
// PipePaths returns the paths a daemon control pipe may live at for an npipe
// address name, in the order both sides must try them: the protected name first,
// then the plain one.
//
// The daemon serves the first it can create, which is the protected name when it
// runs as the service account and the plain one when it runs as an ordinary user,
// as it does in netstack mode. Clients therefore have to try both, and because a
// client cannot tell from the name alone who created the pipe, the plain name is
// only usable once the server's identity has been checked: see
// verifyPipeServer.
//
// A fully qualified path is what the operator asked for and is used as is.
func PipePaths(name string) []string {
if strings.HasPrefix(name, `\\`) {
return []string{name}
}
return []string{PipePath(protectedPrefix + name), PipePath(name)}
}
// IsProtectedPipePath reports whether a pipe path is in the namespace only an
// administrator or LocalSystem can create in, which is what lets a client trust
// such a pipe from its name alone.
func IsProtectedPipePath(path string) bool {
return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix)
}
// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named
// pipe, reporting whether it rewrote the address. Existing installs persist the
// daemon address, so without this an upgraded daemon would keep listening on
// loopback TCP, where callers carry no identity and privileged operations would
// have to be refused for everyone. Only the exact legacy default is rewritten:
// a deliberately chosen custom address is left alone.
func MigrateLegacy(addr string) (string, bool) {
return migrateLegacyForOS(runtime.GOOS, addr)
}
func migrateLegacyForOS(goos, addr string) (string, bool) {
if goos == "windows" && addr == legacyWindowsAddr {
return WindowsPipeAddr, true
}
return addr, false
}

View File

@@ -0,0 +1,15 @@
//go:build !windows
package daemonaddr
import (
"context"
"fmt"
"net"
)
// dialPipePaths is Windows-only: no other platform serves the daemon on a named
// pipe.
func dialPipePaths(context.Context, []string) (net.Conn, error) {
return nil, fmt.Errorf("named pipes are only supported on Windows")
}

View File

@@ -0,0 +1,30 @@
package daemonaddr
import (
"slices"
"testing"
)
// The protected name must be tried before the plain one on both sides: it is the
// one an unprivileged process cannot create, so preferring it is what keeps a
// squatter from owning the name the service daemon would otherwise use.
func TestPipePaths_PrefersTheProtectedName(t *testing.T) {
got := PipePaths("netbird")
want := []string{
`\\.\pipe\ProtectedPrefix\Administrators\netbird`,
`\\.\pipe\netbird`,
}
if !slices.Equal(got, want) {
t.Errorf("PipePaths = %q, want %q", got, want)
}
}
// An operator who passes a full path chose exactly one pipe, so neither side may
// look anywhere else.
func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) {
path := `\\.\pipe\custom-netbird`
got := PipePaths(path)
if !slices.Equal(got, []string{path}) {
t.Errorf("PipePaths = %q, want just %q", got, path)
}
}

View File

@@ -0,0 +1,59 @@
//go:build windows
package daemonaddr
import (
"context"
"errors"
"fmt"
"net"
"github.com/Microsoft/go-winio"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// dialPipePaths connects to the first path that answers with a pipe server this
// client may trust, and returns the last error when none does.
func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) {
var lastErr error
for _, path := range paths {
conn, err := dialPipe(ctx, path)
if err != nil {
log.Debugf("dial daemon pipe %s: %v", path, err)
lastErr = err
continue
}
// A pipe in the protected namespace could only have been created by an
// administrator or LocalSystem, so its name is the guarantee. Any other
// name has to be checked, because any local user can create one.
if !IsProtectedPipePath(path) {
if err := ipcauth.PipeServerTrusted(conn); err != nil {
if closeErr := conn.Close(); closeErr != nil {
log.Debugf("close untrusted pipe %s: %v", path, closeErr)
}
lastErr = fmt.Errorf("%s: %w", path, err)
continue
}
}
return conn, nil
}
if lastErr == nil {
lastErr = errors.New("no daemon pipe to connect to")
}
return nil, lastErr
}
// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION.
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon
// cannot read the caller's token at all. Identification lets the daemon read the
// caller's SID and groups without granting it the ability to act as the caller.
func dialPipe(ctx context.Context, path string) (net.Conn, error) {
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
}

View File

@@ -0,0 +1,9 @@
//go:build !windows
package daemonaddr
// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe
// default to fall back from.
func ResolveDaemonAddr(addr string) string {
return addr
}

View File

@@ -0,0 +1,82 @@
//go:build windows
package daemonaddr
import (
"net"
"strings"
"time"
"github.com/Microsoft/go-winio"
log "github.com/sirupsen/logrus"
)
// probeTimeout bounds each transport probe. Both are local, so a daemon that is
// listening answers immediately and one that is not fails immediately.
const probeTimeout = 300 * time.Millisecond
// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it
// off. When the pipe does not answer it checks the legacy loopback TCP address, so
// a client meeting a daemon that has not restarted since the upgrade can say what
// is wrong, but it does not connect there.
//
// Using that address automatically would be a downgrade the user never asked for:
// any local process can bind 127.0.0.1 while the daemon is not listening, and the
// transport carries no caller identity, so a client that accepted whatever answered
// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An
// operator who needs the legacy address during the upgrade window can still pass
// --daemon-addr explicitly, which is a deliberate choice and still refuses the
// privileged operations.
//
// Only the pipe address is resolved. A custom address is left alone, though passing
// --daemon-addr npipe://netbird explicitly is indistinguishable from the default
// here, so it is treated the same way.
func ResolveDaemonAddr(addr string) string {
if addr != WindowsPipeAddr {
return addr
}
for _, path := range PipePaths("netbird") {
if pipeAvailable(path) {
return addr
}
}
if tcpAvailable(legacyWindowsAddr) {
log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+
"Restart the NetBird service so it serves the pipe. That address is not used automatically: "+
"any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+
"explicitly if you accept that",
WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr)
}
return addr
}
func pipeAvailable(path string) bool {
timeout := probeTimeout
conn, err := winio.DialPipe(path, &timeout)
if err != nil {
return false
}
if err := conn.Close(); err != nil {
log.Debugf("close daemon pipe probe: %v", err)
}
return true
}
func tcpAvailable(addr string) bool {
host := addr
if _, after, ok := strings.Cut(addr, "://"); ok {
host = after
}
conn, err := net.DialTimeout("tcp", host, probeTimeout)
if err != nil {
return false
}
if err := conn.Close(); err != nil {
log.Debugf("close daemon TCP probe: %v", err)
}
return true
}

View File

@@ -229,7 +229,6 @@ scutil_dns.txt (macOS only):
const (
clientLogFile = "client.log"
uiLogFile = "gui-client.log"
errorLogFile = "netbird.err"
stdoutLogFile = "netbird.out"
@@ -248,6 +247,20 @@ type MetricsExporter interface {
Export(w io.Writer) error
}
// LogOpener opens a log file for inclusion in the bundle. It exists so that log
// files whose path was supplied by an IPC caller can be opened under a check
// the daemon defines, instead of being opened with the daemon's privileges
// unconditionally.
type LogOpener func(path string) (*os.File, error)
func openLogFile(path string) (*os.File, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
return f, nil
}
type BundleGenerator struct {
anonymizer *anonymize.Anonymizer
@@ -257,6 +270,7 @@ type BundleGenerator struct {
syncResponse *mgmProto.SyncResponse
logPath string
uiLogPath string
uiLogOpener LogOpener
tempDir string
statePath string
cpuProfile []byte
@@ -285,14 +299,20 @@ type GeneratorDependencies struct {
SyncResponse *mgmProto.SyncResponse
LogPath string
UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one.
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
CPUProfile []byte
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
DaemonVersion string
CliVersion string
// UILogOpener opens the UI log and its rotated siblings. The path comes from
// a local IPC caller, so the daemon must not open it with plain os.Open: the
// opener is where the caller's right to that file is enforced. Defaults to
// os.Open, which is only correct where the path is not caller-supplied
// (mobile).
UILogOpener LogOpener
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
CPUProfile []byte
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
DaemonVersion string
CliVersion string
}
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
@@ -302,6 +322,11 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
logFileCount = 1
}
uiLogOpener := deps.UILogOpener
if uiLogOpener == nil {
uiLogOpener = openLogFile
}
return &BundleGenerator{
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
@@ -310,6 +335,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
syncResponse: deps.SyncResponse,
logPath: deps.LogPath,
uiLogPath: deps.UILogPath,
uiLogOpener: uiLogOpener,
tempDir: deps.TempDir,
statePath: deps.StatePath,
cpuProfile: deps.CPUProfile,
@@ -668,6 +694,12 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
if g.internalConfig.SSHJWTCacheTTL != nil {
configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL))
}
if g.internalConfig.ServerVNCAllowed != nil {
configContent.WriteString(fmt.Sprintf("ServerVNCAllowed: %v\n", *g.internalConfig.ServerVNCAllowed))
}
if g.internalConfig.DisableVNCApproval != nil {
configContent.WriteString(fmt.Sprintf("DisableVNCApproval: %v\n", *g.internalConfig.DisableVNCApproval))
}
configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes))
configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes))
@@ -996,11 +1028,11 @@ func (g *BundleGenerator) addLogfile() error {
logDir := filepath.Dir(g.logPath)
if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil {
if err := g.addSingleLogfile(openLogFile, g.logPath, clientLogFile); err != nil {
return fmt.Errorf("add client log file to zip: %w", err)
}
g.addRotatedLogFiles(logDir, clientLogPrefix)
g.addRotatedLogFiles(openLogFile, logDir, clientLogPrefix)
stdErrLogPath := filepath.Join(logDir, errorLogFile)
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
@@ -1009,11 +1041,11 @@ func (g *BundleGenerator) addLogfile() error {
stdoutLogPath = darwinStdoutLogPath
}
if err := g.addSingleLogfile(stdErrLogPath, errorLogFile); err != nil {
if err := g.addSingleLogfile(openLogFile, stdErrLogPath, errorLogFile); err != nil {
log.Warnf("Failed to add %s to zip: %v", errorLogFile, err)
}
if err := g.addSingleLogfile(stdoutLogPath, stdoutLogFile); err != nil {
if err := g.addSingleLogfile(openLogFile, stdoutLogPath, stdoutLogFile); err != nil {
log.Warnf("Failed to add %s to zip: %v", stdoutLogFile, err)
}
@@ -1030,18 +1062,18 @@ func (g *BundleGenerator) addUILog() error {
return nil
}
if err := g.addSingleLogfile(g.uiLogPath, uiLogFile); err != nil {
if err := g.addSingleLogfile(g.uiLogOpener, g.uiLogPath, configs.UILogFile); err != nil {
return fmt.Errorf("add UI log file to zip: %w", err)
}
g.addRotatedLogFiles(filepath.Dir(g.uiLogPath), uiLogPrefix)
g.addRotatedLogFiles(g.uiLogOpener, filepath.Dir(g.uiLogPath), uiLogPrefix)
return nil
}
// addSingleLogfile adds a single log file to the archive
func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
logFile, err := os.Open(logPath)
func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName string) error {
logFile, err := open(logPath)
if err != nil {
return fmt.Errorf("open log file %s: %w", targetName, err)
}
@@ -1066,8 +1098,8 @@ func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
}
// addSingleLogFileGz adds a single gzipped log file to the archive
func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
f, err := os.Open(logPath)
func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName string) error {
f, err := open(logPath)
if err != nil {
return fmt.Errorf("open gz log file %s: %w", targetName, err)
}
@@ -1114,7 +1146,7 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount.
// prefix is the base log name without extension (e.g. "client", "gui-client");
// the glob matches both files rotated by us and by logrotate on linux.
func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) {
func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix string) {
if g.logFileCount == 0 {
return
}
@@ -1154,9 +1186,9 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) {
for i := 0; i < maxFiles; i++ {
name := filepath.Base(files[i])
if strings.HasSuffix(name, ".gz") {
err = g.addSingleLogFileGz(files[i], name)
err = g.addSingleLogFileGz(open, files[i], name)
} else {
err = g.addSingleLogfile(files[i], name)
err = g.addSingleLogfile(open, files[i], name)
}
if err != nil {
log.Warnf("failed to add rotated log %s: %v", name, err)

View File

@@ -27,7 +27,7 @@ func (g *BundleGenerator) addPlatformLog() error {
}
swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile)
if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil {
if err := g.addSingleLogfile(openLogFile, swiftLogPath, swiftLogFile); err != nil {
// The Swift log is best-effort: the app may not have written it yet.
log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err)
}

View File

@@ -97,7 +97,7 @@ func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount
archive: zip.NewWriter(&buf),
logFileCount: logFileCount,
}
g.addRotatedLogFiles(dir, prefix)
g.addRotatedLogFiles(openLogFile, dir, prefix)
require.NoError(t, g.archive.Close())
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))

View File

@@ -864,6 +864,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
RosenpassEnabled: true,
RosenpassPermissive: true,
ServerSSHAllowed: &bTrue,
ServerVNCAllowed: &bTrue,
DisableVNCApproval: &bTrue,
EnableSSHRoot: &bTrue,
EnableSSHSFTP: &bTrue,
EnableSSHLocalPortForwarding: &bTrue,

View File

@@ -0,0 +1,64 @@
package debug
import (
"archive/zip"
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/configs"
)
// bundleEntries generates a bundle with the given generator and returns the
// set of entry names in the resulting archive.
func bundleEntries(t *testing.T, g *BundleGenerator) map[string]struct{} {
t.Helper()
path, err := g.Generate()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(path) })
data, err := os.ReadFile(path)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
require.NoError(t, err)
names := make(map[string]struct{}, len(zr.File))
for _, f := range zr.File {
names[f.Name] = struct{}{}
}
return names
}
func TestBundleIncludesUILogWhenOpenerAllows(t *testing.T) {
path := filepath.Join(t.TempDir(), configs.UILogFile)
require.NoError(t, os.WriteFile(path, []byte("gui log"), 0600))
g := NewBundleGenerator(GeneratorDependencies{
UILogPath: path,
UILogOpener: openLogFile,
}, BundleConfig{})
require.Contains(t, bundleEntries(t, g), configs.UILogFile)
}
// A UILogOpener that refuses (as the ownership check does for a foreign file)
// keeps the UI log out of the bundle without failing bundle generation.
func TestBundleExcludesUILogWhenOpenerRefuses(t *testing.T) {
path := filepath.Join(t.TempDir(), configs.UILogFile)
require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
g := NewBundleGenerator(GeneratorDependencies{
UILogPath: path,
UILogOpener: func(string) (*os.File, error) {
return nil, fmt.Errorf("not owned by the caller")
},
}, BundleConfig{})
require.NotContains(t, bundleEntries(t, g), configs.UILogFile)
}

View File

@@ -3,10 +3,12 @@ package debug
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"github.com/netbirdio/netbird/upload-server/types"
@@ -14,20 +16,80 @@ import (
const maxBundleUploadSize = 50 * 1024 * 1024
func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) {
response, err := getUploadURL(ctx, url, managementURL)
// requireHTTPS refuses any URL the daemon would fetch or upload to that is not
// https. The daemon runs as root and the bundle carries its logs and state, so a
// plaintext hop is a place to intercept the bundle or the presigned redirect.
// The server-side gate already enforces this for the desktop path; this also
// covers the mobile and job-runner callers that reach this package directly.
// Skipped when the caller opted into an insecure upload (self-hosted server).
func requireHTTPS(what, rawURL string) error {
parsed, err := neturl.Parse(rawURL)
if err != nil {
return fmt.Errorf("parse %s: %w", what, err)
}
if parsed.Scheme != "https" {
return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme)
}
return nil
}
// uploadClient returns the HTTP client for the upload requests. The default
// client verifies TLS and refuses a redirect that would downgrade to a non-https
// hop, so a bundle can never leave over http after an https start. The insecure
// variant accepts http and untrusted certificates, and is only reachable for a
// privileged caller that passed --upload-bundle-insecure (see
// requirePrivilegeForUploadURL).
func uploadClient(insecure bool) *http.Client {
if !insecure {
return &http.Client{CheckRedirect: rejectInsecureRedirect}
}
return &http.Client{
Transport: &http.Transport{
//nolint:gosec // opt-in, privileged, self-hosted upload servers
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},
},
}
}
// rejectInsecureRedirect refuses a redirect to a non-https target and keeps the
// standard library's 10-hop limit that a custom CheckRedirect would otherwise
// disable.
func rejectInsecureRedirect(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
return fmt.Errorf("refusing redirect to non-https URL %s", req.URL.Redacted())
}
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
return nil
}
func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) {
if !insecure {
if err := requireHTTPS("upload service URL", url); err != nil {
return "", err
}
}
response, err := getUploadURL(ctx, url, managementURL, insecure)
if err != nil {
return "", err
}
err = upload(ctx, filePath, response)
if !insecure {
if err := requireHTTPS("upload URL from service", response.URL); err != nil {
return "", err
}
}
err = upload(ctx, filePath, response, insecure)
if err != nil {
return "", err
}
return response.Key, nil
}
func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error {
func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error {
fileData, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file: %w", err)
@@ -52,7 +114,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
req.ContentLength = stat.Size()
req.Header.Set("Content-Type", "application/octet-stream")
putResp, err := http.DefaultClient.Do(req)
putResp, err := uploadClient(insecure).Do(req)
if err != nil {
return fmt.Errorf("upload failed: %v", err)
}
@@ -65,16 +127,23 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
return nil
}
func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) {
id := getURLHash(managementURL)
getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil)
func getUploadURL(ctx context.Context, serviceURL string, managementURL string, insecure bool) (*types.GetURLResponse, error) {
parsed, err := neturl.Parse(serviceURL)
if err != nil {
return nil, fmt.Errorf("parse upload service URL: %w", err)
}
q := parsed.Query()
q.Set("id", getURLHash(managementURL))
parsed.RawQuery = q.Encode()
getReq, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil)
if err != nil {
return nil, fmt.Errorf("create GET request: %w", err)
}
getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue)
resp, err := http.DefaultClient.Do(getReq)
resp, err := uploadClient(insecure).Do(getReq)
if err != nil {
return nil, fmt.Errorf("get presigned URL: %w", err)
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -43,7 +44,7 @@ func TestUpload(t *testing.T) {
fileContent := []byte("test file content")
err := os.WriteFile(file, fileContent, 0640)
require.NoError(t, err)
key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file)
key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file, true)
require.NoError(t, err)
id := getURLHash(testURL)
require.Contains(t, key, id+"/")
@@ -79,3 +80,47 @@ func waitForServer(t *testing.T, addr string) {
}
t.Fatalf("server did not start listening on %s in time", addr)
}
func TestRequireHTTPS(t *testing.T) {
require.NoError(t, requireHTTPS("upload URL", "https://upload.example/path"))
require.Error(t, requireHTTPS("upload URL", "http://upload.example/path"))
require.Error(t, requireHTTPS("upload URL", "ftp://upload.example/path"))
require.Error(t, requireHTTPS("upload URL", "://malformed"))
}
func TestRejectInsecureRedirect(t *testing.T) {
httpsReq, err := http.NewRequest(http.MethodGet, "https://a.example/", nil)
require.NoError(t, err)
require.NoError(t, rejectInsecureRedirect(httpsReq, nil), "https redirect target must be allowed")
httpReq, err := http.NewRequest(http.MethodGet, "http://a.example/", nil)
require.NoError(t, err)
require.Error(t, rejectInsecureRedirect(httpReq, nil), "http redirect target must be refused")
require.Error(t, rejectInsecureRedirect(httpsReq, make([]*http.Request, 10)), "the 10-redirect limit must be enforced")
}
// The secure client refuses to follow an https response that redirects to http,
// so a bundle can't be downgraded onto plaintext mid-flight.
func TestUploadClientRefusesHTTPSToHTTPRedirect(t *testing.T) {
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(plain.Close)
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, plain.URL, http.StatusFound)
}))
t.Cleanup(secure.Close)
client := uploadClient(false)
// Trust the test server's cert without disabling verification globally.
client.Transport = secure.Client().Transport
resp, err := client.Get(secure.URL)
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, err, "redirect from https to http must be refused")
require.Contains(t, err.Error(), "non-https")
}

View File

@@ -34,6 +34,7 @@ import (
"github.com/netbirdio/netbird/client/iface/udpmux"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/acl"
"github.com/netbirdio/netbird/client/internal/approval"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
@@ -135,6 +136,8 @@ type EngineConfig struct {
RosenpassPermissive bool
ServerSSHAllowed bool
ServerVNCAllowed bool
DisableVNCApproval *bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
@@ -233,7 +236,9 @@ type Engine struct {
networkMonitor *networkmonitor.NetworkMonitor
sshServer sshServer
sshServer sshServer
vncSrv vncServer
approvalBroker *approval.Broker
statusRecorder *peer.Status
@@ -340,6 +345,7 @@ func NewEngine(
TURNs: []*stun.URI{},
networkSerial: 0,
statusRecorder: services.StatusRecorder,
approvalBroker: approval.New(services.StatusRecorder),
stateManager: services.StateManager,
portForwardManager: portforward.NewManager(),
checks: services.Checks,
@@ -414,6 +420,10 @@ func (e *Engine) stopLocked() {
log.Warnf("failed to stop SSH server: %v", err)
}
if err := e.stopVNCServer(); err != nil {
log.Warnf("failed to stop VNC server: %v", err)
}
e.cleanupSSHConfig()
if e.ingressGatewayMgr != nil {
@@ -1244,6 +1254,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
e.config.RosenpassEnabled,
e.config.RosenpassPermissive,
&e.config.ServerSSHAllowed,
&e.config.ServerVNCAllowed,
e.config.DisableClientRoutes,
e.config.DisableServerRoutes,
e.config.DisableDNS,
@@ -1299,6 +1310,10 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
}
}
if err := e.updateVNC(); err != nil {
log.Warnf("failed handling VNC server setup: %v", err)
}
state := e.statusRecorder.GetLocalPeerState()
state.IP = e.wgInterface.Address().String()
state.IPv6 = e.wgInterface.Address().IPv6String()
@@ -1599,6 +1614,11 @@ func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.Re
}
}
// VNC auth: always sync, including nil so cleared auth on the management
// side is applied locally, and so it isn't skipped on the RemotePeersIsEmpty
// cleanup path.
e.updateVNCServerAuth(networkMap.GetVncAuth())
// cleanup request, most likely our peer has been deleted
if networkMap.GetRemotePeersIsEmpty() {
err := e.removeAllPeers()
@@ -2113,6 +2133,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
e.config.RosenpassEnabled,
e.config.RosenpassPermissive,
&e.config.ServerSSHAllowed,
&e.config.ServerVNCAllowed,
e.config.DisableClientRoutes,
e.config.DisableServerRoutes,
e.config.DisableDNS,
@@ -2916,3 +2937,16 @@ func decodeRelayIP(b []byte) netip.Addr {
}
return ip.Unmap()
}
// RespondApproval relays the user's decision for a pending approval to
// the broker. viewOnly is honoured only when accept is true. Returns
// true when the request_id matched a live prompt.
func (e *Engine) RespondApproval(requestID string, accept, viewOnly bool) bool {
if e == nil || e.approvalBroker == nil {
return false
}
return e.approvalBroker.Respond(requestID, approval.Decision{
Accept: accept,
ViewOnly: accept && viewOnly,
})
}

View File

@@ -12,10 +12,10 @@ import (
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/iface/netstack"
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
)
@@ -237,22 +237,18 @@ func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error {
return errors.New("wg interface not initialized")
}
wgAddr := e.wgInterface.Address()
serverConfig := &sshserver.Config{
HostKeyPEM: e.config.SSHKey,
JWT: jwtConfig,
HostKeyPEM: e.config.SSHKey,
JWT: jwtConfig,
NetstackNet: e.wgInterface.GetNet(),
NetworkValidation: wgAddr,
}
server := sshserver.New(serverConfig)
wgAddr := e.wgInterface.Address()
server.SetNetworkValidation(wgAddr)
netbirdIP := wgAddr.IP
listenAddr := netip.AddrPortFrom(netbirdIP, sshserver.InternalSSHPort)
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
server.SetNetstackNet(netstackNet)
}
e.configureSSHServer(server)
if err := server.Start(e.ctx, listenAddr); err != nil {

View File

@@ -0,0 +1,329 @@
//go:build !js && !ios && !android
package internal
import (
"context"
"errors"
"fmt"
"net/netip"
log "github.com/sirupsen/logrus"
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/internal/approval"
"github.com/netbirdio/netbird/client/internal/metrics"
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/vnc"
vncserver "github.com/netbirdio/netbird/client/vnc/server"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
)
type vncServer interface {
Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error
AddListener(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error
Stop() error
ActiveSessions() []vncserver.ActiveSessionInfo
}
func (e *Engine) setupVNCPortRedirection() error {
if e.firewall == nil || e.wgInterface == nil {
return nil
}
localAddr := e.wgInterface.Address().IP
if !localAddr.IsValid() {
return errors.New("invalid local NetBird address")
}
if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
return fmt.Errorf("add VNC port redirection: %w", err)
}
log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vnc.ExternalPort, localAddr, vnc.InternalPort)
if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() {
v6 := wgAddr.IPv6
if err := e.firewall.AddInboundDNAT(v6, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
log.Warnf("failed to add IPv6 VNC port redirection: %v", err)
} else {
log.Infof("VNC port redirection: [%s]:%d -> [%s]:%d", v6, vnc.ExternalPort, v6, vnc.InternalPort)
}
}
return nil
}
func (e *Engine) cleanupVNCPortRedirection() error {
if e.firewall == nil || e.wgInterface == nil {
return nil
}
localAddr := e.wgInterface.Address().IP
if !localAddr.IsValid() {
return errors.New("invalid local NetBird address")
}
if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
return fmt.Errorf("remove VNC port redirection: %w", err)
}
if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() {
if err := e.firewall.RemoveInboundDNAT(wgAddr.IPv6, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil {
log.Debugf("failed to remove IPv6 VNC port redirection: %v", err)
}
}
return nil
}
// updateVNC handles starting/stopping the VNC server based on the config flag.
func (e *Engine) updateVNC() error {
if !e.config.ServerVNCAllowed {
if e.vncSrv != nil {
log.Info("VNC server disabled, stopping")
}
return e.stopVNCServer()
}
if e.config.BlockInbound {
log.Info("VNC server disabled because inbound connections are blocked")
return e.stopVNCServer()
}
if e.vncSrv != nil {
return nil
}
return e.startVNCServer()
}
func (e *Engine) startVNCServer() error {
if e.wgInterface == nil {
return errors.New("wg interface not initialized")
}
capturer, injector, ok := newPlatformVNC()
if !ok {
log.Debug("VNC server not supported on this platform")
return nil
}
netbirdIP := e.wgInterface.Address().IP
var sessionRecorder func(vncserver.SessionTick)
if e.clientMetrics != nil {
sessionRecorder = func(t vncserver.SessionTick) {
e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{
Period: t.Period,
BytesOut: t.BytesOut,
Writes: t.Writes,
FBUs: t.FBUs,
MaxFBUBytes: t.MaxFBUBytes,
MaxFBURects: t.MaxFBURects,
MaxWriteBytes: t.MaxWriteBytes,
WriteNanos: t.WriteNanos,
})
}
}
serviceMode := vncNeedsServiceMode()
if serviceMode {
log.Info("VNC: running as system service, enabling service mode (per-session agent proxy)")
}
requireApproval := e.config.DisableVNCApproval == nil || !*e.config.DisableVNCApproval
srv := vncserver.New(vncserver.Config{
Capturer: capturer,
Injector: injector,
IdentityKey: e.config.WgPrivateKey[:],
ServiceMode: serviceMode,
SessionRecorder: sessionRecorder,
NetstackNet: e.wgInterface.GetNet(),
RequireApproval: requireApproval,
Approver: &vncApprover{broker: e.approvalBroker, statusRecorder: e.statusRecorder},
// Session start/stop is invisible to the peer status recorder, so push a
// snapshot ourselves; otherwise the UI's session list goes stale until an
// unrelated peer change happens to fire one.
OnSessionsChanged: e.statusRecorder.NotifyStateChange,
})
listenAddr := netip.AddrPortFrom(netbirdIP, vnc.InternalPort)
network := e.wgInterface.Address().Network
if err := srv.Start(e.ctx, listenAddr, network); err != nil {
return fmt.Errorf("start VNC server: %w", err)
}
if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() {
v6Addr := netip.AddrPortFrom(wgAddr.IPv6, vnc.InternalPort)
if err := srv.AddListener(e.ctx, v6Addr, wgAddr.IPv6Net); err != nil {
log.Warnf("failed to add IPv6 VNC listener: %v", err)
}
}
e.vncSrv = srv
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
if registrar, ok := e.firewall.(interface {
RegisterNetstackService(protocol nftypes.Protocol, port uint16)
}); ok {
registrar.RegisterNetstackService(nftypes.TCP, vnc.InternalPort)
log.Debugf("registered VNC service with netstack for TCP:%d", vnc.InternalPort)
}
}
if err := e.setupVNCPortRedirection(); err != nil {
log.Warnf("setup VNC port redirection: %v", err)
}
log.Info("VNC server enabled")
return nil
}
// updateVNCServerAuth updates VNC fine-grained access control from management.
// A nil vncAuth clears all authorized users and session pubkeys so management
// can revoke access by omitting the field on the next sync.
func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) {
if e.vncSrv == nil {
return
}
vncSrv, ok := e.vncSrv.(*vncserver.Server)
if !ok {
return
}
if vncAuth == nil {
vncSrv.UpdateVNCAuth(&sshauth.Config{})
return
}
protoUsers := vncAuth.GetAuthorizedUsers()
authorizedUsers := make([]sshuserhash.UserIDHash, len(protoUsers))
for i, hash := range protoUsers {
if len(hash) != 16 {
log.Warnf("invalid VNC auth hash length %d, expected 16", len(hash))
return
}
authorizedUsers[i] = sshuserhash.UserIDHash(hash)
}
machineUsers := make(map[string][]uint32)
for osUser, indexes := range vncAuth.GetMachineUsers() {
machineUsers[osUser] = indexes.GetIndexes()
}
sessionPubKeys := make([]sshauth.SessionPubKey, 0, len(vncAuth.GetSessionPubKeys()))
for _, pk := range vncAuth.GetSessionPubKeys() {
pub := pk.GetPubKey()
if len(pub) != 32 {
log.Warnf("VNC session pubkey wrong length %d", len(pub))
continue
}
hash := pk.GetUserIdHash()
if len(hash) != 16 {
log.Warnf("VNC session user id hash wrong length %d", len(hash))
continue
}
sessionPubKeys = append(sessionPubKeys, sshauth.SessionPubKey{
PubKey: pub,
UserIDHash: sshuserhash.UserIDHash(hash),
DisplayName: pk.GetDisplayName(),
})
}
vncSrv.UpdateVNCAuth(&sshauth.Config{
AuthorizedUsers: authorizedUsers,
MachineUsers: machineUsers,
SessionPubKeys: sessionPubKeys,
})
}
// GetVNCServerStatus returns whether the VNC server is running and the list
// of active VNC sessions. The pointer is captured under syncMsgMux so a
// concurrent updateVNC/stopVNCServer cannot swap it out between the nil
// check and the ActiveSessions call.
func (e *Engine) GetVNCServerStatus() (enabled bool, sessions []vncserver.ActiveSessionInfo) {
e.syncMsgMux.Lock()
vncSrv := e.vncSrv
e.syncMsgMux.Unlock()
if vncSrv == nil {
return false, nil
}
return true, vncSrv.ActiveSessions()
}
func (e *Engine) stopVNCServer() error {
if e.vncSrv == nil {
return nil
}
if err := e.cleanupVNCPortRedirection(); err != nil {
log.Warnf("cleanup VNC port redirection: %v", err)
}
if e.wgInterface != nil && e.wgInterface.GetNet() != nil {
if registrar, ok := e.firewall.(interface {
UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
}); ok {
registrar.UnregisterNetstackService(nftypes.TCP, vnc.InternalPort)
}
}
log.Info("stopping VNC server")
err := e.vncSrv.Stop()
e.vncSrv = nil
if err != nil {
return fmt.Errorf("stop VNC server: %w", err)
}
return nil
}
// vncApprover adapts the generic approval.Broker for the VNC server.
type vncApprover struct {
broker *approval.Broker
statusRecorder *peer.Status
}
func (a *vncApprover) Request(ctx context.Context, info vncserver.ApprovalInfo) (vncserver.ApprovalDecision, error) {
// Resolve the source overlay IP to a peer FQDN for the prompt label.
if info.PeerName == "" && info.SourceIP != "" && a.statusRecorder != nil {
if fqdn, ok := a.statusRecorder.PeerByIP(info.SourceIP); ok {
info.PeerName = fqdn
}
}
subject := fmt.Sprintf("VNC connection from %s", displayPeer(info))
meta := map[string]string{
"peer_name": info.PeerName,
"peer_pubkey": info.PeerPubKey,
"source_ip": info.SourceIP,
"mode": info.Mode,
"username": info.Username,
"initiator": info.Initiator,
}
d, err := a.broker.Request(ctx, approval.Prompt{
Kind: approval.KindVNC,
Subject: subject,
Metadata: meta,
})
if err != nil {
return vncserver.ApprovalDecision{}, err
}
return vncserver.ApprovalDecision{ViewOnly: d.ViewOnly}, nil
}
func displayPeer(info vncserver.ApprovalInfo) string {
if info.Initiator != "" {
return info.Initiator
}
if info.PeerName != "" {
return info.PeerName
}
if info.SourceIP != "" {
return info.SourceIP
}
if info.PeerPubKey != "" {
return info.PeerPubKey
}
return "unknown peer"
}

View File

@@ -0,0 +1,31 @@
//go:build freebsd
package internal
import (
"fmt"
log "github.com/sirupsen/logrus"
vncserver "github.com/netbirdio/netbird/client/vnc/server"
)
// newConsoleVNC builds the FreeBSD console fallback: vt(4) framebuffer
// for capture, /dev/uinput for input. The uinput device requires the
// `uinput` kernel module (`kldload uinput`); without it, input init
// fails and we drop to a stub injector so the user still gets a
// view-only screen mirror.
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
poller := vncserver.NewFBPoller("")
w, h := poller.Width(), poller.Height()
if w == 0 || h == 0 {
poller.Close()
return nil, nil, fmt.Errorf("vt framebuffer init failed (vt may not allow mmap on this driver)")
}
if inj, err := vncserver.NewUInputInjector(w, h); err == nil {
return poller, inj, nil
} else {
log.Infof("VNC console: uinput unavailable (%v); view-only mode. Run `kldload uinput` to enable input.", err)
return poller, &vncserver.StubInputInjector{}, nil
}
}

View File

@@ -0,0 +1,30 @@
//go:build linux && !android
package internal
import (
"fmt"
log "github.com/sirupsen/logrus"
vncserver "github.com/netbirdio/netbird/client/vnc/server"
)
// newConsoleVNC builds a framebuffer + uinput VNC backend for boxes
// without a running X server. Used as the auto-fallback when
// newPlatformVNC can't reach X. Returns an error when /dev/fb0 or
// /dev/uinput aren't usable so the caller can drop back to a stub.
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
poller := vncserver.NewFBPoller("")
w, h := poller.Width(), poller.Height()
if w == 0 || h == 0 {
poller.Close()
return nil, nil, fmt.Errorf("framebuffer capturer init failed (is /dev/fb0 readable?)")
}
inj, err := vncserver.NewUInputInjector(w, h)
if err != nil {
log.Debugf("uinput unavailable, falling back to view-only VNC: %v", err)
return poller, &vncserver.StubInputInjector{}, nil
}
return poller, inj, nil
}

View File

@@ -0,0 +1,34 @@
//go:build darwin && !ios
package internal
import (
"os"
log "github.com/sirupsen/logrus"
vncserver "github.com/netbirdio/netbird/client/vnc/server"
)
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
capturer := vncserver.NewMacPoller()
// Prompt for Screen Recording at server-enable time rather than first
// client-connect. The native prompt is far easier for users to act on
// in the moment they toggled VNC on than later when "the screen looks
// like wallpaper" would otherwise be the only clue.
vncserver.PrimeScreenCapturePermission()
injector, err := vncserver.NewMacInputInjector()
if err != nil {
log.Debugf("VNC: macOS input injector: %v", err)
return capturer, &vncserver.StubInputInjector{}, true
}
return capturer, injector, true
}
// vncNeedsServiceMode reports whether the running process is a system
// LaunchDaemon (root, parented by launchd). Daemons sit in the global
// bootstrap namespace and cannot talk to WindowServer; we route capture
// through a per-user agent in that case.
func vncNeedsServiceMode() bool {
return os.Geteuid() == 0 && os.Getppid() == 1
}

View File

@@ -0,0 +1,23 @@
//go:build js || ios || android
package internal
import (
log "github.com/sirupsen/logrus"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
)
type vncServer interface{}
func (e *Engine) updateVNC() error { return nil }
func (e *Engine) updateVNCServerAuth(auth *mgmProto.VNCAuth) {
if auth == nil {
return
}
log.Debugf("ignoring VNC auth push on platform without a VNC server: %d session pubkeys, %d authorized users",
len(auth.GetSessionPubKeys()), len(auth.GetAuthorizedUsers()))
}
func (e *Engine) stopVNCServer() error { return nil }

View File

@@ -0,0 +1,13 @@
//go:build windows
package internal
import vncserver "github.com/netbirdio/netbird/client/vnc/server"
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), true
}
func vncNeedsServiceMode() bool {
return vncserver.GetCurrentSessionID() == 0
}

View File

@@ -0,0 +1,35 @@
//go:build (linux && !android) || freebsd
package internal
import (
log "github.com/sirupsen/logrus"
vncserver "github.com/netbirdio/netbird/client/vnc/server"
)
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
// Prefer X11 when an X server is reachable. NewX11InputInjector probes
// DISPLAY (and /proc) eagerly, so a non-nil error here means no X.
injector, err := vncserver.NewX11InputInjector("", "", "")
if err == nil {
return vncserver.NewX11Poller("", ""), injector, true
}
log.Debugf("VNC: X11 not available: %v", err)
// Fallback for headless / pre-X states (kernel console, login manager
// without X, physical server in recovery): stream the framebuffer and
// inject input via /dev/uinput.
consoleCap, consoleInj, err := newConsoleVNC()
if err == nil {
log.Infof("VNC: using framebuffer console capture (%dx%d)", consoleCap.Width(), consoleCap.Height())
return consoleCap, consoleInj, true
}
log.Debugf("VNC: framebuffer console fallback unavailable: %v", err)
return &vncserver.StubCapturer{}, &vncserver.StubInputInjector{}, false
}
func vncNeedsServiceMode() bool {
return false
}

View File

@@ -0,0 +1,31 @@
//go:build !linux && !darwin && !freebsd && !windows
package ipcauth
import (
"errors"
"net"
"google.golang.org/grpc/credentials"
)
// errUnsupported is returned on platforms with no local peer-identity
// primitive, so consumers fail closed instead of guessing an identity.
var errUnsupported = errors.New("peer identity is not available on this platform")
// NewTransportCredentials returns nil: without a peer-identity primitive the
// daemon cannot authenticate local callers, and the caller must treat that as
// "authorization cannot be enforced".
func NewTransportCredentials() credentials.TransportCredentials {
return nil
}
// PeerIdentity always fails on this platform.
func PeerIdentity(net.Conn) (Identity, error) {
return Identity{}, errUnsupported
}
// ConnIdentity always fails on this platform.
func ConnIdentity(net.Conn) (Identity, error) {
return Identity{}, errUnsupported
}

View File

@@ -0,0 +1,56 @@
//go:build linux || darwin || freebsd
package ipcauth
import (
"context"
"net"
"google.golang.org/grpc/credentials"
)
// NewTransportCredentials returns gRPC transport credentials that expose the
// caller's kernel-authenticated identity via IdentityFromContext. It returns
// nil on platforms that have no peer-identity primitive, which the caller must
// treat as "authorization cannot be enforced".
//
// The handshake exchanges no bytes on the wire, so a client dialing with
// insecure credentials interoperates with a server using these. That keeps
// older CLI and UI binaries working against an upgraded daemon.
func NewTransportCredentials() credentials.TransportCredentials {
return unixCreds{}
}
// ConnIdentity extracts the caller's identity from an accepted local IPC
// connection. It is shared by the gRPC transport credentials and by the JSON
// gateway, which reads the identity of its own HTTP clients.
func ConnIdentity(conn net.Conn) (Identity, error) {
return PeerIdentity(conn)
}
type unixCreds struct{}
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return conn, AuthInfo{}, nil
}
// ServerHandshake extracts the peer identity and fails closed when it cannot
// be read, so a connection whose caller is unknown never reaches a handler.
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,194 @@
//go:build windows
package ipcauth
import (
"context"
"fmt"
"net"
"runtime"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"google.golang.org/grpc/credentials"
)
var (
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
)
// DefaultPipeSDDL is the security descriptor for the daemon control pipe.
//
// D:P protected DACL, no inheritance
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon's service account)
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone
//
// Any local caller may connect, as with a Unix socket at 0666; what a caller may
// actually do is decided from its token, not from the DACL. Remote callers are not
// a concern here: winio.ListenPipe creates the pipe with
// FILE_PIPE_REJECT_REMOTE_CLIENTS, so NPFS rejects connections from other machines
// before the descriptor is consulted.
//
// A deny ACE on the NETWORK SID would not add anything and would break callers:
// that SID is present in any network-logon token, which includes OpenSSH and WinRM
// sessions, so it denies administrators driving the CLI over SSH and denies the
// daemon itself when started from such a session.
func DefaultPipeSDDL() string {
return "D:P(A;;GA;;;SY)(A;;GA;;;WD)"
}
// NewTransportCredentials returns gRPC transport credentials that derive the
// caller's identity from the named-pipe client token.
//
// The client must connect at SECURITY_IDENTIFICATION for the daemon to be able
// to read its token, which is what DialNamedPipe does.
func NewTransportCredentials() credentials.TransportCredentials {
return winpipeCreds{}
}
// 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 by the JSON gateway, which
// reads the identity of its own HTTP clients.
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()))
}
type winpipeCreds struct{}
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return conn, AuthInfo{}, nil
}
// ServerHandshake extracts the connecting client's identity and fails closed
// when the handle or token cannot be read, so a connection whose caller is
// unknown never reaches a handler.
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, usable group
// SIDs, and elevation state by impersonating the pipe client on this thread
// and reading the resulting impersonation token.
func pipeClientIdentity(handle windows.Handle) (id Identity, err error) {
// Impersonation is per-thread, so the goroutine must stay on this thread
// until RevertToSelf, otherwise an unrelated goroutine could inherit the
// impersonated context.
runtime.LockOSThread()
// The thread only goes back to the runtime's pool once it is provably no
// longer impersonating the client. If the revert fails, leaving it locked
// makes Go terminate it when this goroutine exits, which costs one thread
// and keeps a thread running as the client from ever being reused.
clean := false
defer func() {
if clean {
runtime.UnlockOSThread()
}
}()
if err = impersonateNamedPipeClient(handle); err != nil {
clean = true
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
}
defer func() {
// Surface the revert failure only when nothing else failed: leaving
// the thread impersonated is worse than the original error.
revErr := windows.RevertToSelf()
if revErr != nil {
if err == nil {
err = fmt.Errorf("revert impersonation: %w", revErr)
}
return
}
clean = true
}()
// openAsSelf=true opens the token with the daemon's own process context
// rather than the impersonated client's, so the open cannot fail because
// the client lacks access to its own token.
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 func() {
if cerr := token.Close(); cerr != nil {
log.Debugf("close client token: %v", cerr)
}
}()
return identityFromToken(token)
}
// identityFromToken reads the user SID, usable group SIDs and elevation state
// out of a Windows token.
func identityFromToken(token windows.Token) (Identity, error) {
user, err := token.GetTokenUser()
if err != nil {
return Identity{}, fmt.Errorf("read token user: %w", err)
}
groups, err := tokenGroupSIDs(token)
if err != nil {
return Identity{}, err
}
return Identity{
SID: user.User.Sid.String(),
Groups: groups,
Elevated: token.IsElevated(),
}, nil
}
// tokenGroupSIDs returns the SIDs of the groups the token can actually
// exercise. Groups that are disabled or marked deny-only are skipped: a
// UAC-filtered administrator carries BUILTIN\Administrators as deny-only, and
// treating that as membership would hand every admin account privilege it
// cannot currently use.
func tokenGroupSIDs(token windows.Token) ([]string, error) {
tg, err := token.GetTokenGroups()
if err != nil {
return nil, fmt.Errorf("read token groups: %w", err)
}
var sids []string
for _, g := range tg.AllGroups() {
if g.Attributes&windows.SE_GROUP_ENABLED == 0 {
continue
}
if g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
continue
}
sids = append(sids, g.Sid.String())
}
return sids, 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,272 @@
package ipcauth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"slices"
"strconv"
"strings"
"google.golang.org/grpc/metadata"
)
// Metadata keys the local JSON gateway uses to forward the identity of its own
// HTTP client to the daemon. The gateway runs inside the daemon process and
// re-dials the daemon over the control socket, so without forwarding every
// JSON request would appear to come from the daemon itself.
const (
// mdFwd marks a request as forwarded by the JSON gateway. It is always
// set, even when the gateway could not read its client's identity, so the
// daemon can tell "no identity available" apart from "not forwarded".
mdFwd = "x-netbird-fwd"
mdFwdUID = "x-netbird-fwd-uid" // Unix user ID
mdFwdGID = "x-netbird-fwd-gid" // Unix primary group ID
mdFwdSID = "x-netbird-fwd-sid" // Windows user SID
mdFwdGroup = "x-netbird-fwd-group" // Windows group SID, repeated
mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" when elevated
// mdFwdProof proves the forwarded identity was stamped by this process. The
// gateway runs inside the daemon, so a secret held in memory is available to
// the only legitimate producer and to nothing else.
mdFwdProof = "x-netbird-fwd-proof"
)
// forwardKeys is every metadata key the gateway sets. An HTTP client must never
// be able to supply one itself: see IsReservedForwardKey.
var forwardKeys = []string{mdFwd, mdFwdUID, mdFwdGID, mdFwdSID, mdFwdGroup, mdFwdElevated, mdFwdProof}
// forwardProof authenticates the gateway's forwarding metadata. It is generated
// once per daemon process and never leaves it: it is not written to disk, not
// logged, and not sent anywhere except over the daemon's own control socket to
// itself.
//
// Without it, trusting a forwarded identity rests on every layer in front of it
// stripping incoming forwarding keys, and on each key's value shape being
// distinguishable from an injected one. A single injected group SID or an
// injected "elevated" flag has the same shape as a legitimate one, so no
// cardinality rule can catch it. Requiring the proof means metadata that did not
// come from this process is refused whatever it contains.
var forwardProof = mustForwardProof()
func mustForwardProof() string {
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
// Continuing would leave the forwarded path authenticated by a
// predictable value, which is worse than not starting.
panic(fmt.Sprintf("generate identity forwarding proof: %v", err))
}
return hex.EncodeToString(buf[:])
}
// IsReservedForwardKey reports whether a gRPC metadata key belongs to the
// gateway's identity forwarding, and therefore must be dropped when it arrives
// from outside.
//
// grpc-gateway maps "Grpc-Metadata-<key>" request headers into gRPC metadata and
// joins them ahead of the values its own annotators add. Without dropping these,
// an HTTP client could hand the daemon "x-netbird-fwd-uid: 0" and be believed,
// because the daemon trusts forwarded metadata when the transport peer is the
// (privileged) gateway.
func IsReservedForwardKey(key string) bool {
key = strings.ToLower(key)
return slices.Contains(forwardKeys, key)
}
// ForwardIdentityMetadata encodes an HTTP client's identity for the JSON
// gateway to forward to the daemon. When known is false only the marker is
// set, which makes the daemon treat the caller as unidentified rather than as
// the daemon itself.
func ForwardIdentityMetadata(id Identity, known bool) metadata.MD {
md := metadata.MD{}
md.Set(mdFwd, "1")
md.Set(mdFwdProof, forwardProof)
if !known {
return md
}
if id.IsWindows() {
md.Set(mdFwdSID, id.SID)
if len(id.Groups) > 0 {
md.Set(mdFwdGroup, id.Groups...)
}
if id.Elevated {
md.Set(mdFwdElevated, "1")
}
return md
}
md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10))
md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10))
return md
}
// CallerIdentity returns the identity to authorize a request against. For a
// direct connection that is the transport peer's kernel identity. For a
// request relayed by the local JSON gateway it is the identity the gateway
// forwarded, since the transport peer is then the daemon itself.
//
// A forwarded identity is only honoured when the transport peer is the daemon's
// own identity and the metadata carries this process's forwarding proof, so
// forged forwarding metadata gains a caller nothing. A forwarded request that
// carries no identity is reported as unidentified, never as the daemon.
//
// The second return value is false when no identity could be established, and
// callers MUST fail closed in that case.
func CallerIdentity(ctx context.Context) (Identity, bool) {
id, ok := IdentityFromContext(ctx)
if !ok {
return Identity{}, false
}
// A forwarding key that arrives more than once did not come from the gateway
// alone, so nothing about the request can be trusted to describe its caller.
// Refusing outright matters because the alternative reading, "not forwarded",
// would authorize the request as the transport peer, which on the gateway's
// connection is the daemon itself.
if duplicatedForwardKey(ctx) {
return Identity{}, false
}
forwarded := isForwarded(ctx)
// Our own process on the other end of the socket is the JSON gateway, the only
// thing that dials the daemon from inside it. Such a call must carry a
// forwarded identity; without one there is no caller to authorize, and
// treating it as the daemon would authorize whatever reached the JSON socket.
// Only Linux reports the peer PID, so this is a belt on top of the gateway's
// interceptor rather than the sole guarantee.
if id.PID != 0 && int(id.PID) == selfPID && !forwarded {
return Identity{}, false
}
// Only the gateway's own connection may speak for someone else. Being
// privileged is not enough and not the point: the gateway runs inside the
// daemon, so it dials as the daemon's identity whatever user that is, which
// also covers a rootless container.
if !forwarded || !IsDaemonSelf(id) {
return id, true
}
// Speaking for someone else additionally requires the proof only this process
// holds. Refusing is the only safe reading: the transport peer here is the
// daemon itself, so falling back to it would authorize the request as the
// daemon. This is also what makes the forwarded values trustworthy once
// accepted, so they need no shape checks of their own.
if !authenticForward(ctx) {
return Identity{}, false
}
return forwardedIdentity(ctx)
}
// duplicatedForwardKey reports whether any forwarding key carries more than one
// value. The gateway's interceptor sets each key exactly once and replaces what
// was already there, so a repeat means a second source supplied it.
func duplicatedForwardKey(ctx context.Context) bool {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return false
}
for _, key := range forwardKeys {
// Group SIDs are legitimately repeated; the rest identify the caller.
if key == mdFwdGroup {
continue
}
if len(md.Get(key)) > 1 {
return true
}
}
return false
}
// authenticForward reports whether the request carries this process's forwarding
// proof, which only the in-process JSON gateway can supply.
func authenticForward(ctx context.Context) bool {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return false
}
got := mdSingle(md, mdFwdProof)
return subtle.ConstantTimeCompare([]byte(got), []byte(forwardProof)) == 1
}
// isForwarded reports whether the request carries the JSON gateway marker.
func isForwarded(ctx context.Context) bool {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return false
}
return mdSingle(md, mdFwd) != ""
}
// forwardedIdentity decodes the identity the JSON gateway attached.
func forwardedIdentity(ctx context.Context) (Identity, bool) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return Identity{}, false
}
if sid := mdSingle(md, mdFwdSID); sid != "" {
return Identity{
SID: sid,
// Repeated by design, one value per group, and only reachable once
// the forwarding proof has been verified.
Groups: md.Get(mdFwdGroup),
Elevated: mdSingle(md, mdFwdElevated) == "1",
}, true
}
uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32)
if err != nil {
return Identity{}, false
}
id := Identity{UID: uint32(uid)}
if gid, err := strconv.ParseUint(mdSingle(md, mdFwdGID), 10, 32); err == nil {
id.GID = uint32(gid)
}
return id, true
}
// mdSingle returns the value of a forwarded key only when exactly one was
// supplied. The gateway's interceptor sets each key exactly once, so more than one
// value means something else also supplied it, and the whole identity is treated as
// unknown rather than picking a winner. Defence in depth behind the gateway's
// header filter.
func mdSingle(md metadata.MD, key string) string {
if v := md.Get(key); len(v) == 1 {
return v[0]
}
return ""
}
// WithForwardedIdentity stamps id onto a context's outgoing metadata for the JSON
// gateway's call to the daemon, replacing any forwarding keys already present so
// values supplied from outside cannot survive alongside it.
//
// This is deliberately not done with runtime.WithMetadata: grpc-gateway skips its
// annotators entirely when no request header maps to metadata ("if len(pairs) == 0
// { return ctx, nil, nil }", runtime/context.go), which an HTTP/1.0 request with no
// Host header over a unix socket achieves. The daemon would then see an unmarked
// call whose transport peer is the daemon's own identity, and authorize it as the
// daemon. A client interceptor runs for every RPC regardless of headers.
func WithForwardedIdentity(ctx context.Context, id Identity, known bool) context.Context {
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
md = metadata.MD{}
} else {
md = md.Copy()
}
for _, key := range forwardKeys {
delete(md, key)
}
for key, values := range ForwardIdentityMetadata(id, known) {
md[key] = values
}
return metadata.NewOutgoingContext(ctx, md)
}

View File

@@ -0,0 +1,214 @@
package ipcauth
import (
"context"
"testing"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
)
// transportCtx builds a request context as the daemon's transport credentials
// would: the identity of whoever opened the socket, plus whatever metadata the
// request carried.
func transportCtx(id Identity, md metadata.MD) context.Context {
ctx := peer.NewContext(context.Background(), &peer.Peer{
AuthInfo: AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: id,
},
})
if md != nil {
ctx = metadata.NewIncomingContext(ctx, md)
}
return ctx
}
var (
root = Identity{UID: 0}
unprivUser = Identity{UID: 1000, GID: 1000}
)
// asDaemon pins which identity counts as this process for the duration of a test.
// Without it the test binary's own uid decides, which silently changes what
// "the gateway" means.
func asDaemon(t *testing.T, id Identity) {
t.Helper()
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
selfIdentity, selfKnown = id, true
selfMayDelegate = !id.IsPrivileged()
}
func TestCallerIdentity_DirectConnections(t *testing.T) {
t.Run("no transport credentials is not an identity", func(t *testing.T) {
if _, ok := CallerIdentity(context.Background()); ok {
t.Fatal("a caller with no credentials must not be identified")
}
})
t.Run("a direct caller is its transport identity", func(t *testing.T) {
id, ok := CallerIdentity(transportCtx(unprivUser, nil))
if !ok || id.UID != 1000 {
t.Fatalf("got %v ok=%t, want uid 1000", id, ok)
}
})
// The whole point of honouring forwarded metadata only from a privileged
// transport peer: an unprivileged caller can set any metadata it likes on its
// own connection to the daemon socket.
t.Run("an unprivileged caller cannot forge an identity", func(t *testing.T) {
asDaemon(t, root)
forged := metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdGID, "0")
id, ok := CallerIdentity(transportCtx(unprivUser, forged))
if !ok {
t.Fatal("caller should still be identified, as itself")
}
if id.IsPrivileged() || id.UID != 1000 {
t.Fatalf("forged metadata was believed: got %v", id)
}
})
}
func TestCallerIdentity_GatewayForwarding(t *testing.T) {
t.Run("the gateway's client identity is used, not the gateway's own", func(t *testing.T) {
asDaemon(t, root)
md := ForwardIdentityMetadata(unprivUser, true)
id, ok := CallerIdentity(transportCtx(root, md))
if !ok {
t.Fatal("forwarded identity should be usable")
}
if id.IsPrivileged() || id.UID != 1000 {
t.Fatalf("got %v, want the forwarded uid 1000 and not privileged", id)
}
})
t.Run("a privileged gateway client stays privileged", func(t *testing.T) {
asDaemon(t, root)
md := ForwardIdentityMetadata(root, true)
id, ok := CallerIdentity(transportCtx(root, md))
if !ok || !id.IsPrivileged() {
t.Fatalf("got %v ok=%t, want a privileged identity", id, ok)
}
})
// A JSON socket the gateway cannot read peer credentials from (a TCP socket,
// say) must not make every request look like the daemon itself.
t.Run("an unreadable client identity is unknown, not the daemon", func(t *testing.T) {
asDaemon(t, root)
md := ForwardIdentityMetadata(Identity{}, false)
if _, ok := CallerIdentity(transportCtx(root, md)); ok {
t.Fatal("a forwarded request with no identity must not be identified")
}
})
// grpc-gateway turns Grpc-Metadata-<key> headers into gRPC metadata and joins
// them ahead of its annotators' values. If an HTTP client's header survived
// that, this is the shape the daemon would see: the attacker's uid 0 first,
// the real uid second. The gateway filters those headers out, and reading a
// duplicated key as unknown makes the daemon safe even if it did not.
t.Run("a duplicated key from an injected header is not believed", func(t *testing.T) {
asDaemon(t, root)
md := metadata.MD{}
md.Append(mdFwd, "1")
md.Append(mdFwdUID, "0") // injected by the HTTP client
md.Append(mdFwdUID, "1000") // appended by the gateway's annotator
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
t.Fatalf("injected uid was accepted: got %v", id)
}
})
t.Run("a duplicated marker is not believed either", func(t *testing.T) {
asDaemon(t, root)
md := metadata.MD{}
md.Append(mdFwd, "1")
md.Append(mdFwd, "1")
md.Append(mdFwdUID, "1000")
// A repeated marker must not be read as "not forwarded": that would
// authorize the request as the transport peer, which on the gateway's
// connection is the daemon itself.
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
t.Fatalf("a duplicated marker was believed: got %v", id)
}
})
// The layers in front of this (the gateway's header matcher, and its
// interceptor replacing every forwarding key) are what keep outside metadata
// from arriving at all. The proof is what the daemon can check for itself, and
// it is the only defence that works for a value whose legitimate shape is
// indistinguishable from an injected one: a lone group SID, or "elevated".
t.Run("forwarding metadata without this process's proof is refused", func(t *testing.T) {
asDaemon(t, root)
for name, md := range map[string]metadata.MD{
"no proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0"),
"wrong proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdProof, "deadbeef"),
"windows identity without a proof": metadata.Pairs(mdFwd, "1",
mdFwdSID, "S-1-5-21-1-2-3-1001", mdFwdGroup, sidAdministrators, mdFwdElevated, "1"),
} {
t.Run(name, func(t *testing.T) {
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
t.Fatalf("unstamped forwarding metadata was believed: got %v", id)
}
})
}
})
// A caller that reaches the gateway cannot see the proof, so it cannot append
// a group of its own to a genuine forwarded identity: doing so would have to
// go through the interceptor, which replaces the whole set.
t.Run("a group appended to a stamped identity does not survive the interceptor", func(t *testing.T) {
asDaemon(t, root)
injected := metadata.MD{}
injected.Append(mdFwdGroup, sidAdministrators)
ctx := WithForwardedIdentity(metadata.NewOutgoingContext(context.Background(), injected),
Identity{SID: "S-1-5-21-1-2-3-1001"}, true)
out, ok := metadata.FromOutgoingContext(ctx)
if !ok {
t.Fatal("no outgoing metadata")
}
if groups := out.Get(mdFwdGroup); len(groups) != 0 {
t.Fatalf("injected group survived: %v", groups)
}
})
}
func TestIsReservedForwardKey(t *testing.T) {
for _, key := range forwardKeys {
if !IsReservedForwardKey(key) {
t.Errorf("%q must be reserved", key)
}
}
// grpc-gateway canonicalises header names, so the check has to be
// case-insensitive.
if !IsReservedForwardKey("X-Netbird-Fwd-Uid") {
t.Error("the check must be case-insensitive")
}
for _, key := range []string{"authorization", "x-netbird", "x-netbird-fwd-uid-extra", ""} {
if IsReservedForwardKey(key) {
t.Errorf("%q must not be reserved", key)
}
}
}
func TestForwardIdentityMetadata_AlwaysMarksForwarded(t *testing.T) {
for _, tc := range []struct {
name string
id Identity
known bool
}{
{"known unix identity", unprivUser, true},
{"unknown identity", Identity{}, false},
{"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true},
} {
t.Run(tc.name, func(t *testing.T) {
md := ForwardIdentityMetadata(tc.id, tc.known)
if got := md.Get(mdFwd); len(got) != 1 || got[0] != "1" {
t.Fatalf("marker = %v, want exactly one \"1\"", got)
}
})
}
}

View File

@@ -0,0 +1,127 @@
// 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 individual RPCs 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 every consumer must fail closed when no identity is
// available.
package ipcauth
import (
"context"
"fmt"
"slices"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
)
// Well-known Windows SIDs that identify a fully privileged principal.
const (
sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM
sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE
sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE
sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators
)
// Identity is the kernel-authenticated identity of a local IPC caller. The
// zero value is not a valid identity: consumers must only use one obtained
// with a true ok/nil error return.
type Identity struct {
// UID and GID are the caller's Unix user ID and primary group ID. Both are
// 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 time. Only groups that are enabled and not
// deny-only are captured, so a group listed here is one the caller can
// actually exercise. Empty on Unix.
Groups []string
// Elevated reports whether the Windows client token is elevated (running
// as administrator, or an administrator with UAC turned off). Always false
// on Unix, where privilege is uid 0.
Elevated bool
// PID is the caller's process ID where the platform reports it (Linux's
// SO_PEERCRED), and 0 where it does not. It identifies the daemon's own
// process dialling itself, which is what the JSON gateway does, and is never
// used to grant anything.
PID int32
}
// 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, which is what the daemon requires for changes that cross the
// user-to-root boundary.
//
// On Windows the decision comes from the caller's token rather than from
// account names or group RIDs: an elevated token, one of the service accounts
// the daemon itself may run as, or a token with BUILTIN\Administrators
// enabled. A UAC-filtered administrator has that group marked deny-only, and
// deny-only groups are dropped when the identity is captured, so such a
// caller is correctly reported as unprivileged. Domain group memberships
// (Domain Admins and friends) are deliberately not consulted: they say
// nothing about what this token may do on this machine.
func (i Identity) IsPrivileged() bool {
if !i.IsWindows() {
return i.UID == 0
}
if i.Elevated {
return true
}
switch i.SID {
case sidLocalSystem, sidLocalService, sidNetworkService:
return true
}
return slices.Contains(i.Groups, sidAdministrators)
}
// String renders the identity for audit logs and denial messages.
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
// handlers 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, which happens on a TCP daemon socket
// and on platforms without a peer-identity primitive. 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,63 @@
package ipcauth
import (
"fmt"
"os"
)
// OpenOwnedFile opens path for reading on behalf of the IPC caller identified by
// id, and fails unless the opened file is a regular file that id owns.
//
// It exists for the paths a local caller hands to the daemon over the IPC. The
// daemon runs as root, so opening such a path unchecked lets any local user read
// any file through it. Ownership is the invariant that keeps the daemon from
// reading, with its own privileges, a file the caller could not read itself: a
// symlink or hard link planted at the path resolves to a file someone else owns
// and is refused.
//
// The check is made against the open descriptor rather than the path, so
// swapping the path between the check and the read cannot change the answer.
//
// A privileged caller is exempt: it can read the file directly, so refusing it
// here would protect nothing. The regular-file requirement still applies to
// everyone, since a fifo or device planted at the path is never a log file.
func OpenOwnedFile(id Identity, path string) (*os.File, error) {
f, err := openForRead(path)
if err != nil {
return nil, err
}
if err := checkOwnership(id, f); err != nil {
if cerr := f.Close(); cerr != nil {
return nil, fmt.Errorf("%w (close: %v)", err, cerr)
}
return nil, err
}
return f, nil
}
func checkOwnership(id Identity, f *os.File) error {
info, err := f.Stat()
if err != nil {
return fmt.Errorf("stat %s: %w", f.Name(), err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file", f.Name())
}
if IsPrivilegedCaller(id) {
return nil
}
owned, err := fileOwnedBy(id, f)
if err != nil {
return fmt.Errorf("read owner of %s: %w", f.Name(), err)
}
if !owned {
return fmt.Errorf("%s is not owned by the caller (%s)", f.Name(), id)
}
return nil
}

View File

@@ -0,0 +1,64 @@
package ipcauth
import (
"io"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
)
// otherIdentity is an unprivileged caller that owns nothing the test creates.
func otherIdentity(t *testing.T) Identity {
t.Helper()
if runtime.GOOS == "windows" {
return Identity{SID: "S-1-5-21-1-2-3-1001"}
}
return Identity{UID: uint32(os.Geteuid() + 1), GID: uint32(os.Getegid() + 1)}
}
func TestOpenOwnedFileReadsFileOwnedByCaller(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("hello"), 0600))
id, err := CurrentProcessIdentity()
require.NoError(t, err)
f, err := OpenOwnedFile(id, path)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
content, err := io.ReadAll(f)
require.NoError(t, err)
require.Equal(t, "hello", string(content))
}
func TestOpenOwnedFileRefusesFileOwnedByAnother(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
_, err := OpenOwnedFile(otherIdentity(t), path)
require.ErrorContains(t, err, "not owned by the caller")
}
func TestOpenOwnedFileRefusesNonRegularFile(t *testing.T) {
dir := t.TempDir()
// The caller owns the directory, so this is the regular-file requirement
// talking, not the ownership check.
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, dir)
require.ErrorContains(t, err, "not a regular file")
}
func TestOpenOwnedFileRefusesMissingFile(t *testing.T) {
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, filepath.Join(t.TempDir(), "absent.log"))
require.Error(t, err)
}

View File

@@ -0,0 +1,35 @@
//go:build !windows
package ipcauth
import (
"fmt"
"os"
"syscall"
)
// openForRead opens a caller-supplied path without following a symlink at its
// final component and without blocking: a fifo planted at the path would
// otherwise stall the open until a writer appears, and the daemon holds a lock
// while it collects the file.
func openForRead(path string) (*os.File, error) {
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
return f, nil
}
func fileOwnedBy(id Identity, f *os.File) (bool, error) {
info, err := f.Stat()
if err != nil {
return false, err
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return false, fmt.Errorf("no owner information in %T", info.Sys())
}
return stat.Uid == id.UID, nil
}

View File

@@ -0,0 +1,57 @@
//go:build !windows
package ipcauth
import (
"errors"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// A symlink is the shape the arbitrary-read attempt takes: the caller owns the
// link, the file it points at belongs to someone else.
func TestOpenOwnedFileRefusesSymlink(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.log")
require.NoError(t, os.WriteFile(target, []byte("secret"), 0600))
link := filepath.Join(dir, "gui-client.log")
require.NoError(t, os.Symlink(target, link))
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, link)
// O_NOFOLLOW on a symlink reports ELOOP on Linux/Darwin and EMLINK on FreeBSD.
if !errors.Is(err, syscall.ELOOP) && !errors.Is(err, syscall.EMLINK) {
t.Fatalf("symlink open: got %v, want ELOOP or EMLINK", err)
}
}
// A fifo would block the open until a writer showed up, stalling the daemon
// while it holds its lock.
func TestOpenOwnedFileRefusesFifoWithoutBlocking(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, syscall.Mkfifo(path, 0600))
id, err := CurrentProcessIdentity()
require.NoError(t, err)
done := make(chan error, 1)
go func() {
_, err := OpenOwnedFile(id, path)
done <- err
}()
select {
case err := <-done:
require.ErrorContains(t, err, "not a regular file")
case <-time.After(5 * time.Second):
t.Fatal("opening a fifo blocked")
}
}

View File

@@ -0,0 +1,59 @@
//go:build windows
package ipcauth
import (
"fmt"
"os"
"golang.org/x/sys/windows"
)
// openForRead opens a caller-supplied path without following a reparse point at
// it. FILE_FLAG_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW: it
// opens a symlink/junction itself rather than its target, so the regular-file
// check in checkOwnership refuses a link the caller planted to redirect the
// read. FILE_FLAG_BACKUP_SEMANTICS lets a directory open too (as os.Open does),
// so a directory planted at the path is refused as non-regular rather than
// erroring here. The share mode matches os.Open so a log being written stays
// openable.
func openForRead(path string) (*os.File, error) {
p, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, fmt.Errorf("convert path %s: %w", path, err)
}
handle, err := windows.CreateFile(
p,
windows.GENERIC_READ,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
return os.NewFile(uintptr(handle), path), nil
}
// fileOwnedBy compares the file's owner SID with the caller's. Files an elevated
// process creates are owned by BUILTIN\Administrators rather than by the user,
// but such a caller is privileged and never reaches this check.
func fileOwnedBy(id Identity, f *os.File) (bool, error) {
// x/sys/windows GetSecurityInfo frees the OS buffer itself and returns a
// Go-heap copy, so there is nothing to LocalFree here.
sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
if err != nil {
return false, fmt.Errorf("read security info: %w", err)
}
owner, _, err := sd.Owner()
if err != nil {
return false, fmt.Errorf("read owner: %w", err)
}
return id.SID != "" && owner.String() == id.SID, nil
}

View File

@@ -0,0 +1,78 @@
//go:build windows
package ipcauth
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// fileOwnerSID reads the owner SID of path the same way OpenOwnedFile does, so
// the test can construct an Identity that matches (or deliberately does not).
func fileOwnerSID(t *testing.T, path string) string {
t.Helper()
f, err := os.Open(path)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
require.NoError(t, err)
owner, _, err := sd.Owner()
require.NoError(t, err)
return owner.String()
}
// The allow branch of fileOwnedBy is the SID-equality path the legitimate GUI
// flow depends on. Running elevated, a created file is owned by
// BUILTIN\Administrators; an Identity carrying that SID with Elevated=false and
// no groups is unprivileged by IsPrivileged (which reads the token, not the
// SID's RID), so this exercises the real GetSecurityInfo equality rather than
// the privileged-caller shortcut.
func TestOpenOwnedFileWindowsOwnerMatchAllows(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("hello"), 0600))
ownerSID := fileOwnerSID(t, path)
id := Identity{SID: ownerSID}
require.False(t, id.IsPrivileged(), "identity built from the owner SID must be unprivileged for this to test the match path")
f, err := OpenOwnedFile(id, path)
require.NoError(t, err)
_ = f.Close()
}
func TestOpenOwnedFileWindowsOwnerMismatchRefuses(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
other := Identity{SID: "S-1-5-21-9-9-9-9999"}
require.False(t, other.IsPrivileged())
_, err := OpenOwnedFile(other, path)
require.ErrorContains(t, err, "not owned by the caller")
}
// FILE_FLAG_OPEN_REPARSE_POINT must make OpenOwnedFile refuse a symlink the same
// way O_NOFOLLOW does on Unix, so a planted link can't redirect the read to
// another file. Creating a symlink needs a privilege the runner may lack, so the
// test skips rather than fails when it can't.
func TestOpenOwnedFileWindowsRefusesSymlink(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.log")
require.NoError(t, os.WriteFile(target, []byte("secret"), 0600))
link := filepath.Join(dir, "gui-client.log")
if err := os.Symlink(target, link); err != nil {
t.Skipf("cannot create symlink (privilege not held?): %v", err)
}
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, link)
require.Error(t, err, "a symlink must be refused")
}

View File

@@ -0,0 +1,43 @@
//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 via LOCAL_PEERCRED. The xucred is recorded by the
// kernel at connect() time and carries the peer's uid and its group list, of
// which the first entry is the primary group.
func PeerIdentity(conn net.Conn) (Identity, error) {
uc, ok := conn.(*net.UnixConn)
if !ok {
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
}
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("control raw conn: %w", err)
}
if credErr != nil {
return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr)
}
id := Identity{UID: cred.Uid}
if cred.Ngroups > 0 {
id.GID = cred.Groups[0]
}
return id, nil
}

View File

@@ -0,0 +1,39 @@
//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 via SO_PEERCRED. The credentials are recorded by
// the kernel at connect() time and cannot be changed for the life of the
// connection, so they are not spoofable by the caller.
func PeerIdentity(conn net.Conn) (Identity, error) {
uc, ok := conn.(*net.UnixConn)
if !ok {
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
}
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("control raw conn: %w", err)
}
if credErr != nil {
return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr)
}
return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil
}

View File

@@ -0,0 +1,87 @@
//go:build windows
package ipcauth
import (
"fmt"
"net"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
// PipeServerTrusted reports an error unless the pipe behind conn was created by a
// principal this client may hand secrets to. Clients call it for a pipe whose name
// carries no guarantee of its own, which is any name outside the
// ProtectedPrefix\Administrators namespace: that namespace already restricts
// creation to administrators and LocalSystem, while a plain name can be created by
// any local user before the daemon gets there.
//
// The decision is made from the pipe object's owner, not from the serving process,
// because a client cannot open a process running as another user at all, and the
// legitimate case is precisely an unprivileged client talking to a privileged
// daemon. Trusted owners are the service accounts, BUILTIN\Administrators, and
// this client's own user, the last of which is the daemon a user runs themselves
// as in netstack mode. A pipe owned by anyone else gets no setup key, pre-shared
// key or SSO prompt out of this client.
func PipeServerTrusted(conn net.Conn) error {
// go-winio's pipe connection embeds *win32File, which exposes Fd().
fdConn, ok := conn.(interface{ Fd() uintptr })
if !ok {
return fmt.Errorf("connection %T does not expose a pipe handle", conn)
}
owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
if err != nil {
return err
}
if !trustedPipeOwner(owner) {
return fmt.Errorf("pipe owned by %s, which is neither an administrator nor this user", owner)
}
return nil
}
// PipeOwnedBySelf reports whether the pipe behind conn was created by this very
// user, which is how a client recognises a daemon running as itself. Ownership it
// cannot read is reported as false.
func PipeOwnedBySelf(conn net.Conn) bool {
fdConn, ok := conn.(interface{ Fd() uintptr })
if !ok {
return false
}
owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
if err != nil {
log.Debugf("read daemon pipe owner: %v", err)
return false
}
return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
}
// pipeOwnerSID reads the owner of the pipe object a client is connected to. The
// handle was opened with GENERIC_READ, which includes READ_CONTROL, so no extra
// access is needed.
func pipeOwnerSID(handle windows.Handle) (string, error) {
sd, err := windows.GetSecurityInfo(handle, windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION)
if err != nil {
return "", fmt.Errorf("read pipe security info: %w", err)
}
owner, _, err := sd.Owner()
if err != nil {
return "", fmt.Errorf("read pipe owner: %w", err)
}
return owner.String(), nil
}
// trustedPipeOwner reports whether a pipe's owner is a principal a client may
// speak to. An elevated process's objects are owned by BUILTIN\Administrators by
// default, an unelevated one's by the user, which is why both forms appear here.
func trustedPipeOwner(owner string) bool {
switch owner {
case sidLocalSystem, sidLocalService, sidNetworkService, sidAdministrators:
return true
}
return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
}

View File

@@ -0,0 +1,125 @@
package ipcauth
import (
"os"
"runtime"
)
// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it
// raises for an operation that requires root/administrator. Clients match on
// Reason and Domain rather than on the message text, and render the summary and
// command themselves so the user gets guidance instead of a gRPC error dump.
const (
// ErrorReasonPrivilegeRequired identifies the detail.
ErrorReasonPrivilegeRequired = "PRIVILEGE_REQUIRED"
// ErrorDomain scopes the reason to the NetBird daemon.
ErrorDomain = "daemon.netbird.io"
// ErrorMetaSummary is the one-sentence explanation of what was refused.
ErrorMetaSummary = "summary"
// ErrorMetaCommand is the command that performs the same operation with the
// privileges it needs, ready to copy and run.
ErrorMetaCommand = "command"
)
// The identity of the process evaluating callers, captured once because it cannot
// change. selfKnown is false when it could not be read, in which case nothing is
// ever treated as this process. selfMayDelegate additionally requires this
// process to be unprivileged: see IsPrivilegedCaller.
var (
selfIdentity Identity
selfKnown bool
selfMayDelegate bool
// selfPID is this process's PID, used to recognise the daemon dialling itself.
selfPID = os.Getpid()
)
func init() {
id, err := CurrentProcessIdentity()
if err != nil {
return
}
selfIdentity, selfKnown = id, true
// Only an unprivileged daemon delegates its authority to its own identity.
// When it is root or LocalSystem, sharing its identity does not mean sharing
// its power: on Windows a filtered and a full token carry the same SID, so
// matching there would let a non-elevated shell of an administrator account
// act as an administrator, which is the boundary the token check exists to
// keep.
selfMayDelegate = !id.IsPrivileged()
}
// IsDaemonSelf reports whether an identity is this very process. The JSON gateway
// runs inside the daemon and re-dials it locally, so this is what distinguishes
// the gateway from any other caller, whatever user the daemon runs as.
func IsDaemonSelf(id Identity) bool {
if !selfKnown || id.IsWindows() != selfIdentity.IsWindows() {
return false
}
if id.IsWindows() {
return id.SID != "" && id.SID == selfIdentity.SID
}
return id.UID == selfIdentity.UID
}
// IsPrivilegedCaller reports whether an identity may make the changes the daemon
// restricts to the platform administrator. This is the daemon's own rule and
// cannot be evaluated by a client, which does not know what the daemon runs as.
//
// Beyond root/administrator it accepts a caller running as the daemon's own
// identity when the daemon is itself unprivileged. That keeps a rootless container
// working, where there is no uid 0 at all, and a Windows daemon in netstack mode,
// which needs no administrator rights. In those setups a caller sharing the
// daemon's identity can already rewrite the config files it reads and replace the
// binary it runs, so refusing it a config change would protect nothing; and an
// unprivileged daemon cannot hand out a root shell in the first place.
func IsPrivilegedCaller(id Identity) bool {
if id.IsPrivileged() {
return true
}
return selfMayDelegate && IsDaemonSelf(id)
}
// SelfDelegatesTo returns the identity this process delegates its authority to,
// and whether it delegates at all. Only an unprivileged daemon does: see
// IsPrivilegedCaller. It exists so a refusal can name who may actually perform the
// operation, because on such a host root is neither required nor necessarily
// available.
func SelfDelegatesTo() (Identity, bool) {
if !selfKnown || !selfMayDelegate {
return Identity{}, false
}
return selfIdentity, true
}
// PrivilegedActor names the principal a privileged operation requires, for use
// in messages shown to the user.
func PrivilegedActor() string {
if runtime.GOOS == "windows" {
return "administrator privileges"
}
return "root"
}
// ElevatedCommand renders a command so that running it grants the privileges the
// operation needs. Windows has no in-line equivalent of sudo, so the command is
// returned unchanged and the user is expected to run it from an elevated
// terminal.
func ElevatedCommand(command string) string {
if runtime.GOOS == "windows" {
return command
}
return "sudo " + command
}
// UpCommand renders an elevated `netbird up` with the given flags, preceded by a
// `down`. The down is what makes the command work on a connected client: `netbird
// up` prints "Already connected" and returns without applying any config flag, so
// on its own the command would appear to do nothing. It is a no-op, exit 0, when
// the client is not connected.
//
// ";" rather than "&&" so the line can be pasted into any of the shells a user
// might have: PowerShell 5.1, still the default on Windows Server, rejects "&&"
// as a syntax error.
func UpCommand(flags string) string {
return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags)
}

View File

@@ -0,0 +1,134 @@
package ipcauth
import "testing"
// The self rule is the one place privilege is granted to something other than the
// platform administrator, so its two guards matter: it must apply only when the
// daemon is itself unprivileged, and only to a caller with the daemon's identity.
func TestIsPrivilegedCaller_SelfRule(t *testing.T) {
tests := []struct {
name string
// self stands in for the process the daemon runs as.
self Identity
selfKnown bool
caller Identity
want bool
}{
{
name: "root is privileged whatever the daemon runs as",
self: Identity{UID: 1000},
selfKnown: true,
caller: Identity{UID: 0},
want: true,
},
{
name: "an unprivileged daemon delegates to its own user (rootless container)",
self: Identity{UID: 1000},
selfKnown: true,
caller: Identity{UID: 1000},
want: true,
},
{
name: "an unprivileged daemon delegates to nobody else",
self: Identity{UID: 1000},
selfKnown: true,
caller: Identity{UID: 1001},
want: false,
},
{
// The daemon is root on a normal install, so sharing its identity is
// already covered by being root; nothing else may match.
name: "a root daemon delegates to nobody",
self: Identity{UID: 0},
selfKnown: true,
caller: Identity{UID: 1000},
want: false,
},
{
// Windows netstack mode: the daemon needs no administrator rights.
name: "an unprivileged windows daemon delegates to its own SID",
self: Identity{SID: "S-1-5-21-1-2-3-1001"},
selfKnown: true,
caller: Identity{SID: "S-1-5-21-1-2-3-1001"},
want: true,
},
{
name: "an unprivileged windows daemon delegates to no other SID",
self: Identity{SID: "S-1-5-21-1-2-3-1001"},
selfKnown: true,
caller: Identity{SID: "S-1-5-21-1-2-3-1002"},
want: false,
},
{
// The UAC boundary: a filtered and a full token of the same account
// carry the same SID but not the same power, so an elevated daemon must
// never delegate to its own SID.
name: "an elevated windows daemon does not delegate to its own SID",
self: Identity{SID: "S-1-5-21-1-2-3-500", Elevated: true},
selfKnown: true,
caller: Identity{SID: "S-1-5-21-1-2-3-500"},
want: false,
},
{
name: "LocalSystem is privileged on its own merits, not by delegation",
self: Identity{SID: sidLocalSystem},
selfKnown: true,
caller: Identity{SID: sidLocalSystem},
want: true, // LocalSystem is privileged on its own merits
},
{
name: "identities of different kinds never match",
self: Identity{UID: 1000},
selfKnown: true,
caller: Identity{SID: "S-1-5-21-1-2-3-1001"},
want: false,
},
{
name: "an unknown self identity delegates to nobody",
self: Identity{},
selfKnown: false,
caller: Identity{UID: 1000},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
selfIdentity, selfKnown = tt.self, tt.selfKnown
selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged()
if got := IsPrivilegedCaller(tt.caller); got != tt.want {
t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t",
tt.caller, tt.self, got, tt.want)
}
})
}
}
// The real process must never accidentally delegate: a test binary running as a
// normal user is unprivileged, so it may match itself, but nothing else.
func TestIsPrivilegedCaller_ThisProcess(t *testing.T) {
id, err := CurrentProcessIdentity()
if err != nil {
t.Skipf("cannot read this process's identity: %v", err)
}
// This process is always allowed to act as itself: either it is privileged, or
// it is unprivileged and therefore delegates to its own identity.
if !IsPrivilegedCaller(id) {
t.Errorf("this process %v was refused its own identity", id)
}
// A caller that is neither root nor this process must be refused, whatever
// this process happens to be.
other := Identity{UID: id.UID + 1}
if id.IsWindows() {
other = Identity{SID: id.SID + "9"}
}
if IsPrivilegedCaller(other) {
t.Errorf("an unrelated identity %v was treated as privileged", other)
}
}

View File

@@ -0,0 +1,17 @@
//go:build !windows
package ipcauth
import "os"
// CurrentProcessIdentity returns this process's identity as the daemon would
// see it if this process connected to the local IPC. It lets a client (the UI)
// decide up front whether a privileged operation can succeed, without a
// round-trip and without duplicating the rules: the answer comes from the same
// Identity.IsPrivileged the daemon applies.
func CurrentProcessIdentity() (Identity, error) {
return Identity{
UID: uint32(os.Geteuid()),
GID: uint32(os.Getegid()),
}, nil
}

View File

@@ -0,0 +1,35 @@
//go:build windows
package ipcauth
import (
"fmt"
"golang.org/x/sys/windows"
)
// CurrentProcessIdentity returns this process's identity as the daemon would see
// it if this process connected to the local IPC. It lets a client (the UI)
// decide up front whether a privileged operation can succeed, without a
// round-trip and without duplicating the rules: the answer comes from the same
// Identity.IsPrivileged the daemon applies to the token it reads off the pipe.
func CurrentProcessIdentity() (Identity, error) {
// A pseudo-token, so it must not be closed.
token := windows.GetCurrentProcessToken()
user, err := token.GetTokenUser()
if err != nil {
return Identity{}, fmt.Errorf("read token user: %w", err)
}
groups, err := tokenGroupSIDs(token)
if err != nil {
return Identity{}, err
}
return Identity{
SID: user.User.Sid.String(),
Groups: groups,
Elevated: token.IsElevated(),
}, nil
}

View File

@@ -120,6 +120,36 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
m.trimLocked()
}
func (m *influxDBMetrics) RecordVNCSessionTick(_ context.Context, agentInfo AgentInfo, tick VNCSessionTick) {
tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s",
agentInfo.DeploymentType.String(),
agentInfo.Version,
agentInfo.OS,
agentInfo.Arch,
agentInfo.peerID,
)
m.mu.Lock()
defer m.mu.Unlock()
m.samples = append(m.samples, influxSample{
measurement: "netbird_vnc_traffic",
tags: tags,
fields: map[string]float64{
"period_seconds": tick.Period.Seconds(),
"bytes_out": float64(tick.BytesOut),
"writes": float64(tick.Writes),
"fbus": float64(tick.FBUs),
"max_fbu_bytes": float64(tick.MaxFBUBytes),
"max_fbu_rects": float64(tick.MaxFBURects),
"max_write_bytes": float64(tick.MaxWriteBytes),
"write_time_seconds": float64(tick.WriteNanos) / 1e9,
},
timestamp: time.Now(),
})
m.trimLocked()
}
func (m *influxDBMetrics) RecordSyncPhase(_ context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,phase=%s",
agentInfo.DeploymentType.String(),

View File

@@ -63,6 +63,11 @@ type metricsImplementation interface {
// RecordLoginDuration records how long the login to management took
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
// RecordVNCSessionTick records a periodic snapshot of one VNC
// session's wire activity. Called once per metricsConn tick interval
// (and once at session close), only when the tick saw activity.
RecordVNCSessionTick(ctx context.Context, agentInfo AgentInfo, tick VNCSessionTick)
// Export exports metrics in InfluxDB line protocol format
Export(w io.Writer) error
@@ -82,6 +87,21 @@ type ClientMetrics struct {
pushCancel context.CancelFunc
}
// VNCSessionTick is one sampling slice of a VNC session's wire activity.
// BytesOut / Writes / FBUs / WriteNanos are deltas observed during this
// tick; Max* fields are the high-water marks observed during the tick.
// Period is the wall-clock duration the deltas cover.
type VNCSessionTick struct {
Period time.Duration
BytesOut uint64
Writes uint64
FBUs uint64
MaxFBUBytes uint64
MaxFBURects uint64
MaxWriteBytes uint64
WriteNanos uint64
}
// ConnectionStageTimestamps holds timestamps for each connection stage
type ConnectionStageTimestamps struct {
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
@@ -131,6 +151,18 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
}
// RecordVNCSessionTick records a periodic snapshot of one VNC session.
func (c *ClientMetrics) RecordVNCSessionTick(ctx context.Context, tick VNCSessionTick) {
if c == nil {
return
}
c.mu.RLock()
agentInfo := c.agentInfo
c.mu.RUnlock()
c.impl.RecordVNCSessionTick(ctx, agentInfo, tick)
}
// RecordSyncPhase records the duration of a single sub-phase of sync processing
func (c *ClientMetrics) RecordSyncPhase(ctx context.Context, phase string, duration time.Duration) {
if c == nil {

View File

@@ -76,6 +76,9 @@ func (m *mockMetrics) RecordSyncPhase(_ context.Context, _ AgentInfo, _ string,
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
}
func (m *mockMetrics) RecordVNCSessionTick(_ context.Context, _ AgentInfo, _ VNCSessionTick) {
}
func (m *mockMetrics) Export(w io.Writer) error {
if m.exportData != "" {
_, err := w.Write([]byte(m.exportData))

View File

@@ -1330,6 +1330,15 @@ func (d *Status) SubscribeToEvents() *EventSubscription {
}
}
// HasEventSubscribers reports whether any client is currently subscribed
// to the daemon's SystemEvent stream. Used by the VNC approval broker to
// fail closed when no UI is connected to prompt the user.
func (d *Status) HasEventSubscribers() bool {
d.eventMux.Lock()
defer d.eventMux.Unlock()
return len(d.eventStreams) > 0
}
// UnsubscribeFromEvents removes an event subscription
func (d *Status) UnsubscribeFromEvents(sub *EventSubscription) {
if sub == nil {

View File

@@ -70,6 +70,8 @@ type ConfigInput struct {
StateFilePath string
PreSharedKey *string
ServerSSHAllowed *bool
ServerVNCAllowed *bool
DisableVNCApproval *bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
@@ -124,6 +126,8 @@ type Config struct {
RosenpassEnabled bool
RosenpassPermissive bool
ServerSSHAllowed *bool
ServerVNCAllowed *bool
DisableVNCApproval *bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
@@ -456,6 +460,33 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if input.ServerVNCAllowed != nil {
if config.ServerVNCAllowed == nil || *input.ServerVNCAllowed != *config.ServerVNCAllowed {
if *input.ServerVNCAllowed {
log.Infof("enabling VNC server")
} else {
log.Infof("disabling VNC server")
}
config.ServerVNCAllowed = input.ServerVNCAllowed
updated = true
}
} else if config.ServerVNCAllowed == nil {
config.ServerVNCAllowed = util.False()
updated = true
}
if input.DisableVNCApproval != nil {
if config.DisableVNCApproval == nil || *input.DisableVNCApproval != *config.DisableVNCApproval {
if *input.DisableVNCApproval {
log.Infof("disabling VNC connection approval prompt")
} else {
log.Infof("enabling VNC connection approval prompt")
}
config.DisableVNCApproval = input.DisableVNCApproval
updated = true
}
}
if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) {
if *input.EnableSSHRoot {
log.Infof("enabling SSH root login")
@@ -712,6 +743,8 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
}
applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
applyBool(mdm.KeyAllowServerVNC, func(v bool) { bv := v; config.ServerVNCAllowed = &bv })
applyBool(mdm.KeyDisableVNCApproval, func(v bool) { bv := v; config.DisableVNCApproval = &bv })
applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
@@ -746,6 +779,13 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
// appended for https or ":80" for http. The serviceName parameter is
// used to contextualise error messages. On success returns the parsed
// *url.URL; on failure returns a non-nil error.
// ParseServiceURL normalises a service URL exactly as the config layer does when
// it stores one, so callers comparing a requested URL against a stored one do not
// have to reimplement the scheme validation and default-port handling.
func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) {
return parseURL(serviceName, serviceURL)
}
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
if err != nil {

View File

@@ -130,6 +130,36 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
}
func TestApply_MDMVNCKeys(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "config.json")
// Seed without MDM: VNC off, approval prompt on.
withMDMPolicy(t, mdm.NewPolicy(nil))
_, err := UpdateOrCreateConfig(ConfigInput{
ConfigPath: tmp,
ServerVNCAllowed: boolPtr(false),
DisableVNCApproval: boolPtr(false),
})
require.NoError(t, err)
// MDM enforces VNC on and disables the approval prompt.
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
mdm.KeyAllowServerVNC: true,
mdm.KeyDisableVNCApproval: true,
}))
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
require.NoError(t, err)
require.NotNil(t, cfg)
require.NotNil(t, cfg.ServerVNCAllowed)
assert.True(t, *cfg.ServerVNCAllowed, "MDM override should flip on-disk false to true")
require.NotNil(t, cfg.DisableVNCApproval)
assert.True(t, *cfg.DisableVNCApproval)
assert.True(t, cfg.Policy().HasKey(mdm.KeyAllowServerVNC))
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableVNCApproval))
}
func TestApply_MDMLazyConnection(t *testing.T) {
cases := []struct {
name string

View File

@@ -75,6 +75,14 @@ func New(filePath string) *Manager {
}
}
// FilePath returns the path of the underlying state file.
func (m *Manager) FilePath() string {
if m == nil {
return ""
}
return m.filePath
}
// Start starts the state manager periodic save routine
func (m *Manager) Start() {
if m == nil {

View File

@@ -262,7 +262,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path)
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false)
if err != nil {
return "", fmt.Errorf("upload debug bundle: %w", err)
}

View File

@@ -54,7 +54,7 @@ func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.
}
}()
key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path)
key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path, false)
if err != nil {
log.Errorf("failed to upload debug bundle: %v", err)
return "", fmt.Errorf("upload debug bundle: %w", err)

View File

@@ -21,6 +21,8 @@ var allKeys = []string{
KeyBlockInbound,
KeyDisableMetricsCollection,
KeyAllowServerSSH,
KeyAllowServerVNC,
KeyDisableVNCApproval,
KeyDisableAutoConnect,
KeyDisableAutostart,
KeyPreSharedKey,

View File

@@ -36,6 +36,8 @@ const (
KeyBlockInbound = "blockInbound"
KeyDisableMetricsCollection = "disableMetricsCollection"
KeyAllowServerSSH = "allowServerSSH"
KeyAllowServerVNC = "allowServerVNC"
KeyDisableVNCApproval = "disableVNCApproval"
KeyDisableAutoConnect = "disableAutoConnect"
// KeyDisableAutostart suppresses the GUI's fresh-install
// launch-on-login default and marks the Settings toggle as

File diff suppressed because it is too large Load Diff

View File

@@ -1099,6 +1099,30 @@ func request_DaemonService_ExposeService_0(ctx context.Context, marshaler runtim
return stream, metadata, nil
}
func request_DaemonService_RespondApproval_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq RespondApprovalRequest
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.RespondApproval(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_DaemonService_RespondApproval_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq RespondApprovalRequest
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.RespondApproval(ctx, &protoReq)
return msg, metadata, err
}
func request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq WailsUIReadyRequest
@@ -1977,6 +2001,26 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
})
mux.Handle(http.MethodPost, pattern_DaemonService_RespondApproval_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/RespondApproval", runtime.WithHTTPPathPattern("/daemon.DaemonService/RespondApproval"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_DaemonService_RespondApproval_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_RespondApproval_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_WailsUIReady_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -2802,6 +2846,23 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM
}
forward_DaemonService_ExposeService_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_RespondApproval_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/RespondApproval", runtime.WithHTTPPathPattern("/daemon.DaemonService/RespondApproval"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_DaemonService_RespondApproval_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_RespondApproval_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_DaemonService_WailsUIReady_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -2868,6 +2929,7 @@ var (
pattern_DaemonService_StopCPUProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StopCPUProfile"}, ""))
pattern_DaemonService_GetInstallerResult_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetInstallerResult"}, ""))
pattern_DaemonService_ExposeService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ExposeService"}, ""))
pattern_DaemonService_RespondApproval_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RespondApproval"}, ""))
pattern_DaemonService_WailsUIReady_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WailsUIReady"}, ""))
)
@@ -2917,5 +2979,6 @@ var (
forward_DaemonService_StopCPUProfile_0 = runtime.ForwardResponseMessage
forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage
forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream
forward_DaemonService_RespondApproval_0 = runtime.ForwardResponseMessage
forward_DaemonService_WailsUIReady_0 = runtime.ForwardResponseMessage
)

View File

@@ -152,6 +152,14 @@ service DaemonService {
// ExposeService exposes a local port via the NetBird reverse proxy
rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {}
// RespondApproval delivers the user's accept/deny decision for a
// pending user-approval prompt. The daemon pushes the prompt as a
// SystemEvent with category APPROVAL and metadata key "request_id";
// the UI calls this RPC with the same request_id to unblock whichever
// subsystem (VNC, SSH, ...) is waiting. The "kind" metadata key tells
// the UI which subsystem the prompt belongs to.
rpc RespondApproval(RespondApprovalRequest) returns (RespondApprovalResponse) {}
// WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
@@ -242,6 +250,10 @@ message LoginRequest {
optional bool disableSSHAuth = 38;
optional int32 sshJWTCacheTTL = 39;
optional bool disable_ipv6 = 40;
optional bool serverVNCAllowed = 41;
optional bool disableVNCApproval = 42;
}
message LoginResponse {
@@ -362,12 +374,16 @@ message GetConfigResponse {
bool disable_ipv6 = 27;
bool serverVNCAllowed = 28;
bool disableVNCApproval = 29;
// mDMManagedFields lists the names of configuration keys whose value is
// currently enforced by an MDM policy. Names match mdm.Key* constants
// (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
// render the corresponding inputs as read-only and display a "managed
// by MDM" indicator.
repeated string mDMManagedFields = 28;
repeated string mDMManagedFields = 30;
}
// PeerState contains the latest state of a peer
@@ -452,6 +468,25 @@ message SSHServerState {
repeated SSHSessionInfo sessions = 2;
}
// VNCSessionInfo contains information about an active VNC session
message VNCSessionInfo {
string remoteAddress = 1;
string mode = 2;
string username = 3;
// userID is the Noise-verified session identity (hashed user ID from
// the ACL session-key entry), empty when auth is disabled.
string userID = 4;
// initiator is the human-readable display name of the dashboard user
// who minted the SessionPubKey, when known.
string initiator = 5;
}
// VNCServerState contains the latest state of the VNC server
message VNCServerState {
bool enabled = 1;
repeated VNCSessionInfo sessions = 2;
}
// FullStatus contains the full state held by the Status instance
message FullStatus {
ManagementState managementState = 1;
@@ -472,6 +507,7 @@ message FullStatus {
// on it to know when to re-fetch ListNetworks via the push stream, instead
// of polling on every status snapshot.
uint64 networksRevision = 11;
VNCServerState vncServerState = 12;
}
// Networks
@@ -536,6 +572,10 @@ message DebugBundleRequest {
string uploadURL = 4;
uint32 logFileCount = 5;
string cliVersion = 6;
// uploadInsecure allows uploading to an http endpoint or one with an
// untrusted TLS certificate. Restricted to privileged callers; for
// self-hosted upload servers.
bool uploadInsecure = 7;
}
message DebugBundleResponse {
@@ -667,6 +707,7 @@ message SystemEvent {
AUTHENTICATION = 2;
CONNECTIVITY = 3;
SYSTEM = 4;
APPROVAL = 5;
}
string id = 1;
@@ -757,6 +798,10 @@ message SetConfigRequest {
optional bool disableSSHAuth = 33;
optional int32 sshJWTCacheTTL = 34;
optional bool disable_ipv6 = 35;
optional bool serverVNCAllowed = 36;
optional bool disableVNCApproval = 37;
}
message SetConfigResponse{}
@@ -1046,3 +1091,18 @@ message StartBundleCaptureRequest {
message StartBundleCaptureResponse {}
message StopBundleCaptureRequest {}
message StopBundleCaptureResponse {}
message RespondApprovalRequest {
// request_id matches the SystemEvent metadata key emitted by the daemon
// when a subsystem awaits user approval for an inbound connection.
string request_id = 1;
// accept is true if the user approved the request, false if they
// denied it. A missing or unknown request_id is treated as a no-op.
bool accept = 2;
// view_only signals that the user granted the connection but withheld
// input control. Only meaningful when accept is true; ignored when
// accept is false.
bool view_only = 3;
}
message RespondApprovalResponse {}

View File

@@ -64,6 +64,7 @@ const (
DaemonService_StopCPUProfile_FullMethodName = "/daemon.DaemonService/StopCPUProfile"
DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult"
DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService"
DaemonService_RespondApproval_FullMethodName = "/daemon.DaemonService/RespondApproval"
DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady"
)
@@ -167,6 +168,13 @@ type DaemonServiceClient interface {
GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error)
// ExposeService exposes a local port via the NetBird reverse proxy
ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error)
// RespondApproval delivers the user's accept/deny decision for a
// pending user-approval prompt. The daemon pushes the prompt as a
// SystemEvent with category APPROVAL and metadata key "request_id";
// the UI calls this RPC with the same request_id to unblock whichever
// subsystem (VNC, SSH, ...) is waiting. The "kind" metadata key tells
// the UI which subsystem the prompt belongs to.
RespondApproval(ctx context.Context, in *RespondApprovalRequest, opts ...grpc.CallOption) (*RespondApprovalResponse, error)
// WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
@@ -667,6 +675,16 @@ func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServi
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type DaemonService_ExposeServiceClient = grpc.ServerStreamingClient[ExposeServiceEvent]
func (c *daemonServiceClient) RespondApproval(ctx context.Context, in *RespondApprovalRequest, opts ...grpc.CallOption) (*RespondApprovalResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RespondApprovalResponse)
err := c.cc.Invoke(ctx, DaemonService_RespondApproval_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(WailsUIReadyResponse)
@@ -777,6 +795,13 @@ type DaemonServiceServer interface {
GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error)
// ExposeService exposes a local port via the NetBird reverse proxy
ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error
// RespondApproval delivers the user's accept/deny decision for a
// pending user-approval prompt. The daemon pushes the prompt as a
// SystemEvent with category APPROVAL and metadata key "request_id";
// the UI calls this RPC with the same request_id to unblock whichever
// subsystem (VNC, SSH, ...) is waiting. The "kind" metadata key tells
// the UI which subsystem the prompt belongs to.
RespondApproval(context.Context, *RespondApprovalRequest) (*RespondApprovalResponse, error)
// WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI
// only cares whether the daemon implements it: an Unimplemented response
// means the daemon predates this UI and is too old to drive it.
@@ -926,6 +951,9 @@ func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *Ins
func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error {
return status.Error(codes.Unimplemented, "method ExposeService not implemented")
}
func (UnimplementedDaemonServiceServer) RespondApproval(context.Context, *RespondApprovalRequest) (*RespondApprovalResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RespondApproval not implemented")
}
func (UnimplementedDaemonServiceServer) WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method WailsUIReady not implemented")
}
@@ -1732,6 +1760,24 @@ func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStr
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type DaemonService_ExposeServiceServer = grpc.ServerStreamingServer[ExposeServiceEvent]
func _DaemonService_RespondApproval_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RespondApprovalRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).RespondApproval(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DaemonService_RespondApproval_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).RespondApproval(ctx, req.(*RespondApprovalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DaemonService_WailsUIReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(WailsUIReadyRequest)
if err := dec(in); err != nil {
@@ -1921,6 +1967,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetInstallerResult",
Handler: _DaemonService_GetInstallerResult_Handler,
},
{
MethodName: "RespondApproval",
Handler: _DaemonService_RespondApproval_Handler,
},
{
MethodName: "WailsUIReady",
Handler: _DaemonService_WailsUIReady_Handler,

View File

@@ -111,7 +111,7 @@ func (s *Server) StartCapture(req *proto.StartCaptureRequest, stream proto.Daemo
return status.Errorf(codes.Internal, "create capture session: %v", err)
}
engine, err := s.claimCapture(sess)
engine, err := s.claimCapture(sess, func() { pw.Close() })
if err != nil {
sess.Stop()
pw.Close()
@@ -190,10 +190,7 @@ func (s *Server) StartBundleCapture(_ context.Context, req *proto.StartBundleCap
s.stopBundleCaptureLocked()
s.cleanupBundleCapture()
if s.activeCapture != nil {
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
}
s.evictActiveCaptureLocked()
engine, err := s.getCaptureEngineLocked()
if err != nil {
@@ -304,29 +301,58 @@ func (s *Server) cleanupBundleCapture() {
s.bundleCapture = nil
}
// claimCapture reserves the engine's capture slot for sess. Returns
// FailedPrecondition if another capture is already active.
func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) {
// claimCapture reserves the engine's capture slot for sess. If another
// capture is already running it is evicted: a previous streaming session
// whose gRPC client died and never freed the slot stays stuck otherwise,
// and a bundle capture is just informational state.
func (s *Server) claimCapture(sess *capture.Session, cancel func()) (*internal.Engine, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.activeCapture != nil {
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
}
s.evictActiveCaptureLocked()
engine, err := s.getCaptureEngineLocked()
if err != nil {
return nil, err
}
s.activeCapture = sess
s.activeCaptureCancel = cancel
return engine, nil
}
// evictActiveCaptureLocked tears down whatever capture currently owns
// the engine slot so a fresh claim can succeed. Caller must hold mutex.
func (s *Server) evictActiveCaptureLocked() {
if s.activeCapture == nil {
return
}
if s.bundleCapture != nil && s.bundleCapture.sess == s.activeCapture {
log.Infof("evicting running bundle capture to start a new capture")
s.stopBundleCaptureLocked()
return
}
log.Infof("evicting previous streaming capture to start a new one")
prev := s.activeCapture
cancel := s.activeCaptureCancel
if engine, err := s.getCaptureEngineLocked(); err == nil {
if err := engine.SetCapture(nil); err != nil {
log.Debugf("clear previous capture: %v", err)
}
}
s.activeCapture = nil
s.activeCaptureCancel = nil
prev.Stop()
if cancel != nil {
cancel()
}
}
// releaseCapture clears the active-capture owner if it still matches sess.
func (s *Server) releaseCapture(sess *capture.Session) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.activeCapture == sess {
s.activeCapture = nil
s.activeCaptureCancel = nil
}
}
@@ -341,6 +367,7 @@ func (s *Server) clearCaptureIfOwner(sess *capture.Session, engine *internal.Eng
log.Debugf("clear capture: %v", err)
}
s.activeCapture = nil
s.activeCaptureCancel = nil
}
func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) {

View File

@@ -7,18 +7,62 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"runtime/pprof"
"strings"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/version"
)
// DebugBundle creates a debug bundle and returns the location.
func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
func (s *Server) DebugBundle(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure()); err != nil {
return nil, err
}
// The UI log is opened as whoever asked for this bundle, so a caller only
// collects a log it owns (privileged callers excepted). ok is false on a
// socket that carries no identity, which skips the UI log.
callerID, callerIdentified := ipcauth.CallerIdentity(callerCtx)
path, managementURL, err := s.generateDebugBundle(req, uiLogOpener(callerID, callerIdentified))
if err != nil {
return nil, err
}
if req.GetUploadURL() == "" {
return &proto.DebugBundleResponse{Path: path}, nil
}
// The upload runs without s.mutex held: it does network I/O to a possibly
// slow destination and must not block the other RPCs that take the lock. The
// bounded context is a backstop against a hung connection.
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
key, err := debug.UploadDebugBundle(uploadCtx, req.GetUploadURL(), managementURL, path, req.GetUploadInsecure())
if err != nil {
log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
}
log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key)
return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
}
// generateDebugBundle builds the bundle under s.mutex and returns its path plus
// the management URL captured under the lock, so the caller can run the upload
// without holding the lock.
func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener debug.LogOpener) (path string, managementURL string, err error) {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -68,6 +112,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
SyncResponse: syncResponse,
LogPath: s.logFile,
UILogPath: s.uiLogPath,
UILogOpener: uiOpener,
CPUProfile: cpuProfileData,
CapturePath: capturePath,
RefreshStatus: refreshStatus,
@@ -82,23 +127,16 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
},
)
path, err := bundleGenerator.Generate()
path, err = bundleGenerator.Generate()
if err != nil {
return nil, fmt.Errorf("generate debug bundle: %w", err)
return "", "", fmt.Errorf("generate debug bundle: %w", err)
}
if req.GetUploadURL() == "" {
return &proto.DebugBundleResponse{Path: path}, nil
}
key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path)
if err != nil {
log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
if s.config != nil && s.config.ManagementURL != nil {
managementURL = s.config.ManagementURL.String()
}
log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key)
return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
return path, managementURL, nil
}
// GetLogLevel gets the current logging level for the server.
@@ -138,12 +176,34 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (
// RegisterUILog records the desktop UI's absolute log path so DebugBundle can
// collect the GUI log. The daemon runs as root and can't resolve the user's
// config dir, so the UI reports it. Last-writer-wins (one UI per socket).
func (s *Server) RegisterUILog(_ context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
//
// The path arrives over an IPC any local user can reach and is later opened by
// a root daemon, so it is constrained to the file name the UI writes and to a
// local absolute path. Authorization happens when DebugBundle opens it: the
// bundle refuses a file its requester does not own. A caller the daemon cannot
// identify cannot register a path at all.
func (s *Server) RegisterUILog(callerCtx context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
if _, ok := ipcauth.CallerIdentity(callerCtx); !ok {
return nil, gstatus.Error(codes.PermissionDenied,
"registering a UI log path requires a control channel that carries the caller's identity")
}
path := filepath.Clean(req.GetPath())
if !filepath.IsAbs(path) || filepath.Base(path) != uiLogFileName {
return nil, gstatus.Errorf(codes.InvalidArgument, "UI log path must be an absolute path ending in %s", uiLogFileName)
}
// filepath.IsAbs accepts a Windows UNC path (\\host\share\...) and a device
// path (\\.\, \\?\); opening one would make the root daemon reach a remote
// or device namespace. Require a plain local path.
if strings.HasPrefix(path, `\\`) {
return nil, gstatus.Error(codes.InvalidArgument, "UI log path must be a local path, not a UNC or device path")
}
s.mutex.Lock()
defer s.mutex.Unlock()
s.uiLogPath = req.GetPath()
log.Infof("registered UI log path: %s", s.uiLogPath)
s.uiLogPath = path
log.Infof("registered UI log path %s", s.uiLogPath)
return &proto.RegisterUILogResponse{}, nil
}

View File

@@ -0,0 +1,99 @@
//go:build !android && !ios
package server
import (
"context"
"fmt"
"net/url"
"os"
"strings"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/configs"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/upload-server/types"
)
// uiLogFileName is the only file name the daemon accepts as a UI log path. The
// UI (writer), this validation, and the bundle collector all read it from
// configs so they cannot drift.
const uiLogFileName = configs.UILogFile
// uiLogOpener opens the registered UI log, and its rotated siblings, on behalf
// of the caller requesting the bundle: OpenOwnedFile then collects the log only
// when that caller owns it (or is privileged). identified is false on a socket
// that carries no caller identity, in which case nothing is opened.
func uiLogOpener(id ipcauth.Identity, identified bool) debug.LogOpener {
return func(path string) (*os.File, error) {
if !identified {
return nil, fmt.Errorf("bundle requester has no verified identity")
}
return ipcauth.OpenOwnedFile(id, path)
}
}
// requirePrivilegeForUploadURL restricts where the daemon may send a debug
// bundle. The bundle holds the daemon's own logs and state, and the daemon
// fetches the upload URL itself, so an unrestricted endpoint turns the daemon
// into both an exfiltration channel and a request forwarder that reaches
// services only it can talk to.
//
// The upload service NetBird publishes is open to any caller, since that is what
// the CLI and the desktop UI use. Any other endpoint, self-hosted upload servers
// included, requires a privileged caller. Plaintext is refused for everyone: the
// daemon fetches the URL and then PUTs the bundle to whatever that fetch returns,
// so an http hop is a place to intercept the bundle or the redirect.
//
// insecure relaxes transport security (http, or an untrusted TLS certificate)
// for a self-hosted server. It weakens a root-privileged upload, so it is
// refused for an unprivileged caller regardless of the host.
func requirePrivilegeForUploadURL(ctx context.Context, rawURL string, insecure bool) error {
if rawURL == "" {
return nil
}
parsed, err := url.Parse(rawURL)
if err != nil {
return gstatus.Errorf(codes.InvalidArgument, "parse upload URL: %v", err)
}
// --insecure relaxes https to http or an untrusted certificate; it does not
// widen the URL to arbitrary schemes, so a host and http/https are required
// before the insecure branch takes over.
if parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return gstatus.Errorf(codes.InvalidArgument, "upload URL must be http or https with a host")
}
if insecure {
return denyPrivileged(ctx,
"uploading a debug bundle without transport security (--upload-bundle-insecure)",
ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-insecure --upload-bundle-url <url>"))
}
if parsed.Scheme != "https" {
return gstatus.Errorf(codes.InvalidArgument, "upload URL must use https, got scheme %q", parsed.Scheme)
}
if isDefaultUploadService(parsed) {
return nil
}
return denyPrivileged(ctx,
"uploading a debug bundle to an upload service other than the default one",
ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-url <url>"))
}
// isDefaultUploadService reports whether the URL points at the upload service
// NetBird runs. Only the host is compared: the service's path may differ between
// releases, and the host is what decides who receives the bundle.
func isDefaultUploadService(parsed *url.URL) bool {
defaultURL, err := url.Parse(types.DefaultBundleURL)
if err != nil {
return false
}
return parsed.Scheme == defaultURL.Scheme && strings.EqualFold(parsed.Host, defaultURL.Host)
}

View File

@@ -0,0 +1,157 @@
//go:build !android && !ios
package server
import (
"os"
"path/filepath"
"runtime"
"testing"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/upload-server/types"
)
func TestRegisterUILogRefusesUnidentifiedCaller(t *testing.T) {
s := &Server{}
_, err := s.RegisterUILog(noIdentityCtx(), &proto.RegisterUILogRequest{
Path: filepath.Join(t.TempDir(), uiLogFileName),
})
if gstatus.Code(err) != codes.PermissionDenied {
t.Fatalf("code = %v, want PermissionDenied", gstatus.Code(err))
}
}
func TestRegisterUILogRefusesForeignPath(t *testing.T) {
secret := "/etc/shadow"
if runtime.GOOS == "windows" {
secret = `C:\Windows\System32\config\SAM`
}
tests := []struct {
name string
path string
}{
{"empty", ""},
{"relative", filepath.Join("netbird", uiLogFileName)},
{"another file", secret},
{"directory of the log", t.TempDir()},
{"unc path", `\\attacker\share\` + uiLogFileName},
{"device path", `\\.\C:\` + uiLogFileName},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := &Server{}
_, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: tc.path})
if gstatus.Code(err) != codes.InvalidArgument {
t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
}
if s.uiLogPath != "" {
t.Fatalf("path %q was recorded despite the refusal", s.uiLogPath)
}
})
}
}
func TestRegisterUILogRecordsPath(t *testing.T) {
s := &Server{}
path := filepath.Join(t.TempDir(), uiLogFileName)
if _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: path}); err != nil {
t.Fatalf("register: %v", err)
}
if s.uiLogPath != path {
t.Fatalf("path = %q, want %q", s.uiLogPath, path)
}
}
// The UI log is opened as the bundle requester, so a second local user cannot
// collect a log they do not own, and an unidentified requester collects nothing.
func TestUILogOpenerBindsToRequester(t *testing.T) {
path := filepath.Join(t.TempDir(), uiLogFileName)
if err := os.WriteFile(path, []byte("log line"), 0600); err != nil {
t.Fatalf("write log: %v", err)
}
// A different unprivileged user than the file's owner: refused.
if _, err := uiLogOpener(unprivilegedIdentity(), true)(path); err == nil {
t.Fatal("expected a file the requester does not own to be refused")
}
// No verified identity: refused.
if _, err := uiLogOpener(ipcauth.Identity{}, false)(path); err == nil {
t.Fatal("expected an unidentified requester to be refused")
}
// The requester that owns the file: allowed. The test process created it, so
// its own identity is the owner (and a privileged runner is exempt anyway).
owner, err := ipcauth.CurrentProcessIdentity()
if err != nil {
t.Fatalf("current identity: %v", err)
}
f, err := uiLogOpener(owner, true)(path)
if err != nil {
t.Fatalf("expected the owning requester to be allowed, got %v", err)
}
_ = f.Close()
}
func TestRequirePrivilegeForUploadURL(t *testing.T) {
tests := []struct {
name string
url string
insecure bool
unprivOK bool
invalid bool
rootAlso bool
}{
{name: "no upload", url: "", unprivOK: true},
{name: "default service", url: types.DefaultBundleURL, unprivOK: true},
{name: "default service, other path", url: "https://upload.debug.netbird.io/other", unprivOK: true},
{name: "loopback exfiltration endpoint", url: "https://127.0.0.1:8080/upload-url", rootAlso: true},
{name: "custom upload service", url: "https://attacker.example/upload-url", rootAlso: true},
{name: "plaintext default host", url: "http://upload.debug.netbird.io/upload-url", invalid: true},
{name: "plaintext custom host", url: "http://attacker.example/upload-url", invalid: true},
{name: "unsupported scheme", url: "file:///etc/shadow", invalid: true},
// insecure relaxes transport security; privileged only, whatever the host.
{name: "insecure http custom", url: "http://selfhosted.local/upload-url", insecure: true, rootAlso: true},
{name: "insecure https custom", url: "https://selfhosted.local/upload-url", insecure: true, rootAlso: true},
{name: "insecure default host", url: types.DefaultBundleURL, insecure: true, rootAlso: true},
// --insecure must not widen the URL to non-http(s) schemes or a hostless URL.
{name: "insecure file scheme", url: "file:///etc/shadow", insecure: true, invalid: true},
{name: "insecure hostless", url: "https:///upload-url", insecure: true, invalid: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := requirePrivilegeForUploadURL(userCtx(), tc.url, tc.insecure)
switch {
case tc.invalid:
if gstatus.Code(err) != codes.InvalidArgument {
t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
}
return
case tc.unprivOK:
assertAllowed(t, err)
return
default:
assertDenied(t, err)
}
if tc.rootAlso {
assertAllowed(t, requirePrivilegeForUploadURL(rootCtx(), tc.url, tc.insecure))
}
})
}
}

View File

@@ -0,0 +1,51 @@
package server
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
)
// The daemon takes guardedConfigMu before s.mutex. authorizeAndPrepareLogin
// takes s.mutex while holding guardedConfigMu, so a SetConfig that grabbed
// s.mutex first and then waited for guardedConfigMu would deadlock the daemon
// against a concurrent login: two unprivileged IPC calls are enough.
//
// The held guardedConfigMu below stands in for that login. While SetConfig waits
// for it, s.mutex must stay free, otherwise the login waiting for s.mutex could
// never release guardedConfigMu.
func TestSetConfig_TakesGuardedConfigMuBeforeServerMutex(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
s.guardedConfigMu.Lock()
done := make(chan error, 1)
go func() {
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
})
done <- err
}()
require.Never(t, func() bool {
if !s.mutex.TryLock() {
return true
}
s.mutex.Unlock()
return false
}, 500*time.Millisecond, 10*time.Millisecond,
"SetConfig held s.mutex while waiting for guardedConfigMu, which deadlocks against a concurrent login")
s.guardedConfigMu.Unlock()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("SetConfig did not finish after guardedConfigMu was released")
}
}

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