mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-01 13:21:29 +02:00
Compare commits
123 Commits
fix/empty-
...
embedded-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86d775fbc2 | ||
|
|
a117c19b0d | ||
|
|
0780a806f2 | ||
|
|
59c75127a4 | ||
|
|
9c3347021a | ||
|
|
feecb993f4 | ||
|
|
f51fadf8d4 | ||
|
|
aad2702a14 | ||
|
|
7516aa6473 | ||
|
|
aed60a2432 | ||
|
|
234abd7a08 | ||
|
|
90d2687e48 | ||
|
|
abd5022e18 | ||
|
|
8e919b4ee9 | ||
|
|
1836616608 | ||
|
|
548cbfb1e0 | ||
|
|
0e2bd094bb | ||
|
|
05a75d80eb | ||
|
|
abe8bc1b53 | ||
|
|
2b051ad250 | ||
|
|
152ba28d9f | ||
|
|
eb6e8dc905 | ||
|
|
125250c5df | ||
|
|
cec9ea8c00 | ||
|
|
02e7c0e5d2 | ||
|
|
dbc7b846b2 | ||
|
|
fd7bf982c3 | ||
|
|
d568084d61 | ||
|
|
c1eecaac26 | ||
|
|
f2c79201b3 | ||
|
|
2fdc3aea4c | ||
|
|
144dfbc12c | ||
|
|
6c9465df54 | ||
|
|
6cd5d6084f | ||
|
|
3bcacffd2c | ||
|
|
65f302b698 | ||
|
|
2f67841b1e | ||
|
|
bf2fb2fd44 | ||
|
|
4e3e3ce6d3 | ||
|
|
5e2830be8a | ||
|
|
f557e665a5 | ||
|
|
fa57eedaf5 | ||
|
|
7cb6388349 | ||
|
|
1f912be673 | ||
|
|
8d329da591 | ||
|
|
8e72967bbe | ||
|
|
c29ef638f4 | ||
|
|
97b7b010f5 | ||
|
|
030c57150f | ||
|
|
0f03c612d1 | ||
|
|
1cc5967198 | ||
|
|
412193c602 | ||
|
|
5e67febf57 | ||
|
|
ee348ba007 | ||
|
|
3d3055dc7f | ||
|
|
2f4ddf0796 | ||
|
|
98d533c8e8 | ||
|
|
ef4ea2e311 | ||
|
|
b41d11bbbe | ||
|
|
f37e228cc2 | ||
|
|
640a267556 | ||
|
|
17359cdc1e | ||
|
|
7e5846a1ee | ||
|
|
517bea0daf | ||
|
|
896530fd82 | ||
|
|
354fd004c7 | ||
|
|
c28e41e82b | ||
|
|
02b9fe704b | ||
|
|
5e200fa571 | ||
|
|
7d61975f6c | ||
|
|
62b36112ea | ||
|
|
df9a6fb020 | ||
|
|
b1b04f9ec6 | ||
|
|
fe15688f20 | ||
|
|
2285db2b62 | ||
|
|
b3f0f53a23 | ||
|
|
5eec9962ba | ||
|
|
393c102f45 | ||
|
|
b41fbad5e1 | ||
|
|
24a5f2252c | ||
|
|
9d189bb3e8 | ||
|
|
8e2505b59c | ||
|
|
97bc1eebde | ||
|
|
32a5a061b8 | ||
|
|
d927ef468a | ||
|
|
d3f3e08035 | ||
|
|
6bb66e0fad | ||
|
|
bc407527f4 | ||
|
|
5543404188 | ||
|
|
c2fdf62f1f | ||
|
|
b9f5264e36 | ||
|
|
97d0a6776f | ||
|
|
7e7e056f3a | ||
|
|
785f94d13f | ||
|
|
bfb6750b13 | ||
|
|
f5e1057127 | ||
|
|
ee393d0e62 | ||
|
|
0b8fc5da59 | ||
|
|
2d0a54f31a | ||
|
|
61ec8d67de | ||
|
|
76add0b9b2 | ||
|
|
a11341f57a | ||
|
|
b135d462d6 | ||
|
|
da37a28951 | ||
|
|
4f884d9f30 | ||
|
|
2bed8b641b | ||
|
|
b4f696272a | ||
|
|
6d937af7a0 | ||
|
|
db5b6cfbb7 | ||
|
|
e75948753a | ||
|
|
047cc958b5 | ||
|
|
cd005ef9a9 | ||
|
|
44ed0c1992 | ||
|
|
d6d3fa95c7 | ||
|
|
fa90283781 | ||
|
|
8bf13b0d0c | ||
|
|
a8541a1529 | ||
|
|
94068d3ebc | ||
|
|
738c585ee7 | ||
|
|
9b5541d17d | ||
|
|
7123e6d1f4 | ||
|
|
62cf9e873b | ||
|
|
9f0aa1ce26 |
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
@@ -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/*"
|
||||
|
||||
10
.github/pull_request_template.md
vendored
10
.github/pull_request_template.md
vendored
@@ -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).
|
||||
|
||||
|
||||
287
CONTRIBUTING.md
287
CONTRIBUTING.md
@@ -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
|
||||
@@ -334,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:
|
||||
|
||||
@@ -301,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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
106
client/cmd/up.go
106
client/cmd/up.go
@@ -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
100
client/cmd/vnc_agent.go
Normal 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,
|
||||
}
|
||||
18
client/cmd/vnc_agent_darwin.go
Normal file
18
client/cmd/vnc_agent_darwin.go
Normal 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
|
||||
}
|
||||
77
client/cmd/vnc_agent_dropprivs_darwin.go
Normal file
77
client/cmd/vnc_agent_dropprivs_darwin.go
Normal 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
|
||||
}
|
||||
55
client/cmd/vnc_agent_dropprivs_darwin_test.go
Normal file
55
client/cmd/vnc_agent_dropprivs_darwin_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
11
client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go
Normal file
11
client/cmd/vnc_agent_dropprivs_testhelpers_darwin.go
Normal 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())
|
||||
}
|
||||
14
client/cmd/vnc_agent_dropprivs_windows.go
Normal file
14
client/cmd/vnc_agent_dropprivs_windows.go
Normal 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
|
||||
}
|
||||
15
client/cmd/vnc_agent_windows.go
Normal file
15
client/cmd/vnc_agent_windows.go
Normal 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
16
client/cmd/vnc_flags.go
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
60
client/firewall/nftables/legacy_rule_linux_test.go
Normal file
60
client/firewall/nftables/legacy_rule_linux_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
219
client/internal/approval/broker.go
Normal file
219
client/internal/approval/broker.go
Normal 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()
|
||||
}
|
||||
434
client/internal/approval/broker_test.go
Normal file
434
client/internal/approval/broker_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
62
client/internal/approval/fingerprint_test.go
Normal file
62
client/internal/approval/fingerprint_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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,
|
||||
|
||||
64
client/internal/debug/uilog_test.go
Normal file
64
client/internal/debug/uilog_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
329
client/internal/engine_vnc.go
Normal file
329
client/internal/engine_vnc.go
Normal 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"
|
||||
}
|
||||
31
client/internal/engine_vnc_console_freebsd.go
Normal file
31
client/internal/engine_vnc_console_freebsd.go
Normal 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
|
||||
}
|
||||
}
|
||||
30
client/internal/engine_vnc_console_linux.go
Normal file
30
client/internal/engine_vnc_console_linux.go
Normal 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
|
||||
}
|
||||
34
client/internal/engine_vnc_darwin.go
Normal file
34
client/internal/engine_vnc_darwin.go
Normal 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
|
||||
}
|
||||
23
client/internal/engine_vnc_stub.go
Normal file
23
client/internal/engine_vnc_stub.go
Normal 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 }
|
||||
13
client/internal/engine_vnc_windows.go
Normal file
13
client/internal/engine_vnc_windows.go
Normal 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
|
||||
}
|
||||
35
client/internal/engine_vnc_x11.go
Normal file
35
client/internal/engine_vnc_x11.go
Normal 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
|
||||
}
|
||||
63
client/internal/ipcauth/ownedfile.go
Normal file
63
client/internal/ipcauth/ownedfile.go
Normal 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
|
||||
}
|
||||
64
client/internal/ipcauth/ownedfile_test.go
Normal file
64
client/internal/ipcauth/ownedfile_test.go
Normal 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)
|
||||
}
|
||||
35
client/internal/ipcauth/ownedfile_unix.go
Normal file
35
client/internal/ipcauth/ownedfile_unix.go
Normal 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
|
||||
}
|
||||
57
client/internal/ipcauth/ownedfile_unix_test.go
Normal file
57
client/internal/ipcauth/ownedfile_unix_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
59
client/internal/ipcauth/ownedfile_windows.go
Normal file
59
client/internal/ipcauth/ownedfile_windows.go
Normal 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
|
||||
}
|
||||
78
client/internal/ipcauth/ownedfile_windows_test.go
Normal file
78
client/internal/ipcauth/ownedfile_windows_test.go
Normal 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")
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,6 +21,8 @@ var allKeys = []string{
|
||||
KeyBlockInbound,
|
||||
KeyDisableMetricsCollection,
|
||||
KeyAllowServerSSH,
|
||||
KeyAllowServerVNC,
|
||||
KeyDisableVNCApproval,
|
||||
KeyDisableAutoConnect,
|
||||
KeyDisableAutostart,
|
||||
KeyPreSharedKey,
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
99
client/server/debug_gate.go
Normal file
99
client/server/debug_gate.go
Normal 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)
|
||||
}
|
||||
157
client/server/debug_gate_test.go
Normal file
157
client/server/debug_gate_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
51
client/server/lock_order_test.go
Normal file
51
client/server/lock_order_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,8 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
|
||||
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
||||
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
||||
conflictBool(mdm.KeyAllowServerVNC, msg.ServerVNCAllowed),
|
||||
conflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval),
|
||||
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
||||
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||
@@ -332,6 +334,8 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
|
||||
msg.Mtu != nil ||
|
||||
msg.DisableAutoConnect != nil ||
|
||||
msg.ServerSSHAllowed != nil ||
|
||||
msg.ServerVNCAllowed != nil ||
|
||||
msg.DisableVNCApproval != nil ||
|
||||
msg.NetworkMonitor != nil ||
|
||||
msg.DisableClientRoutes != nil ||
|
||||
msg.DisableServerRoutes != nil ||
|
||||
@@ -370,6 +374,8 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
|
||||
msg.WireguardPort != nil ||
|
||||
msg.DisableAutoConnect != nil ||
|
||||
msg.ServerSSHAllowed != nil ||
|
||||
msg.ServerVNCAllowed != nil ||
|
||||
msg.DisableVNCApproval != nil ||
|
||||
msg.RosenpassPermissive != nil ||
|
||||
len(msg.ExtraIFaceBlacklist) > 0 ||
|
||||
msg.NetworkMonitor != nil ||
|
||||
@@ -418,6 +424,8 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
|
||||
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
|
||||
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
|
||||
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
|
||||
conflictBool(mdm.KeyAllowServerVNC, msg.ServerVNCAllowed),
|
||||
conflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval),
|
||||
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
|
||||
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
|
||||
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
|
||||
|
||||
@@ -72,6 +72,9 @@ type Server struct {
|
||||
// RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle
|
||||
// can collect the GUI log even though the daemon runs as root and can't
|
||||
// resolve the user's config dir. Last-writer-wins (one UI per socket).
|
||||
// DebugBundle opens it on behalf of the bundle requester and refuses a file
|
||||
// that caller does not own, so a local user cannot read another user's log
|
||||
// or a root-only file through it.
|
||||
uiLogPath string
|
||||
|
||||
oauthAuthFlow oauthAuthFlow
|
||||
@@ -119,8 +122,12 @@ type Server struct {
|
||||
captureEnabled bool
|
||||
bundleCapture *bundleCapture
|
||||
// activeCapture is the session currently installed on the engine; guarded by s.mutex.
|
||||
activeCapture *capture.Session
|
||||
networksDisabled bool
|
||||
activeCapture *capture.Session
|
||||
// activeCaptureCancel tears down the streaming pipe/cancel for the
|
||||
// active streaming capture so eviction unblocks the StartCapture RPC
|
||||
// handler. Nil for bundle captures (they own their own context).
|
||||
activeCaptureCancel func()
|
||||
networksDisabled bool
|
||||
|
||||
sleepHandler *sleephandler.SleepHandler
|
||||
|
||||
@@ -393,6 +400,16 @@ func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (i
|
||||
|
||||
// Login uses setup key to prepare configuration for the daemon.
|
||||
func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigRequest) (*proto.SetConfigResponse, error) {
|
||||
// Privilege gate: refuse the parts of the request that would let a local
|
||||
// user turn the root daemon into a root shell. Held across the write so the
|
||||
// config cannot gain the SSH server between the decision and the update.
|
||||
//
|
||||
// Taken before s.mutex: authorizeAndPrepareLogin takes s.mutex while holding
|
||||
// guardedConfigMu, so acquiring the two in the other order here would let a
|
||||
// concurrent login deadlock the daemon.
|
||||
s.guardedConfigMu.Lock()
|
||||
defer s.guardedConfigMu.Unlock()
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
@@ -417,12 +434,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Privilege gate: refuse the parts of the request that would let a local
|
||||
// user turn the root daemon into a root shell. Held across the write so the
|
||||
// config cannot gain the SSH server between the decision and the update.
|
||||
s.guardedConfigMu.Lock()
|
||||
defer s.guardedConfigMu.Unlock()
|
||||
|
||||
stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -512,6 +523,8 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
|
||||
config.RosenpassPermissive = msg.RosenpassPermissive
|
||||
config.DisableAutoConnect = msg.DisableAutoConnect
|
||||
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
||||
config.ServerVNCAllowed = msg.ServerVNCAllowed
|
||||
config.DisableVNCApproval = msg.DisableVNCApproval
|
||||
config.NetworkMonitor = msg.NetworkMonitor
|
||||
config.DisableClientRoutes = msg.DisableClientRoutes
|
||||
config.DisableServerRoutes = msg.DisableServerRoutes
|
||||
@@ -1519,6 +1532,7 @@ func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusReque
|
||||
pbFullStatus := fullStatus.ToProto()
|
||||
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
||||
pbFullStatus.SshServerState = s.getSSHServerState()
|
||||
pbFullStatus.VncServerState = s.getVNCServerState()
|
||||
pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision()
|
||||
statusResponse.FullStatus = pbFullStatus
|
||||
}
|
||||
@@ -1559,6 +1573,38 @@ func (s *Server) getSSHServerState() *proto.SSHServerState {
|
||||
return sshServerState
|
||||
}
|
||||
|
||||
// getVNCServerState retrieves the current VNC server state.
|
||||
func (s *Server) getVNCServerState() *proto.VNCServerState {
|
||||
s.mutex.Lock()
|
||||
connectClient := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
|
||||
if connectClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
engine := connectClient.Engine()
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabled, sessions := engine.GetVNCServerStatus()
|
||||
pbSessions := make([]*proto.VNCSessionInfo, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
pbSessions = append(pbSessions, &proto.VNCSessionInfo{
|
||||
RemoteAddress: sess.RemoteAddress,
|
||||
Mode: sess.Mode,
|
||||
Username: sess.Username,
|
||||
UserID: sess.UserID,
|
||||
Initiator: sess.Initiator,
|
||||
})
|
||||
}
|
||||
return &proto.VNCServerState{
|
||||
Enabled: enabled,
|
||||
Sessions: pbSessions,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
|
||||
func (s *Server) GetPeerSSHHostKey(
|
||||
ctx context.Context,
|
||||
@@ -1944,6 +1990,30 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespondApproval relays the user's accept/deny decision for a pending
|
||||
// approval prompt to the engine's broker. Unknown or already-resolved
|
||||
// request_ids are silently no-op'd so a slow UI cannot deny a prompt the
|
||||
// user already handled (or that already timed out).
|
||||
func (s *Server) RespondApproval(_ context.Context, msg *proto.RespondApprovalRequest) (*proto.RespondApprovalResponse, error) {
|
||||
if msg.GetRequestId() == "" {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "request_id is required")
|
||||
}
|
||||
s.mutex.Lock()
|
||||
connectClient := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
if connectClient == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "client not initialized")
|
||||
}
|
||||
engine := connectClient.Engine()
|
||||
if engine == nil {
|
||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "engine not running")
|
||||
}
|
||||
if !engine.RespondApproval(msg.GetRequestId(), msg.GetAccept(), msg.GetViewOnly()) {
|
||||
log.Debugf("approval response for unknown request_id %s", msg.GetRequestId())
|
||||
}
|
||||
return &proto.RespondApprovalResponse{}, nil
|
||||
}
|
||||
|
||||
func isUnixRunningDesktop() bool {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
||||
return false
|
||||
@@ -2051,6 +2121,8 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
||||
Mtu: int64(cfg.MTU),
|
||||
DisableAutoConnect: cfg.DisableAutoConnect,
|
||||
ServerSSHAllowed: *cfg.ServerSSHAllowed,
|
||||
ServerVNCAllowed: cfg.ServerVNCAllowed != nil && *cfg.ServerVNCAllowed,
|
||||
DisableVNCApproval: cfg.DisableVNCApproval != nil && *cfg.DisableVNCApproval,
|
||||
RosenpassEnabled: cfg.RosenpassEnabled,
|
||||
RosenpassPermissive: cfg.RosenpassPermissive,
|
||||
BlockInbound: cfg.BlockInbound,
|
||||
|
||||
@@ -109,6 +109,30 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) {
|
||||
assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
|
||||
}
|
||||
|
||||
func TestSetConfig_MDMReject_VNCFields(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyAllowServerVNC: true,
|
||||
mdm.KeyDisableVNCApproval: false,
|
||||
}))
|
||||
|
||||
s, ctx, profName, username, _ := setupServerWithProfile(t)
|
||||
|
||||
vncAllowed := false
|
||||
disableApproval := true
|
||||
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
|
||||
ProfileName: profName,
|
||||
Username: username,
|
||||
ServerVNCAllowed: &vncAllowed,
|
||||
DisableVNCApproval: &disableApproval,
|
||||
})
|
||||
|
||||
v := extractViolation(t, err)
|
||||
assert.ElementsMatch(t, []string{
|
||||
mdm.KeyAllowServerVNC,
|
||||
mdm.KeyDisableVNCApproval,
|
||||
}, v.GetFields())
|
||||
}
|
||||
|
||||
func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: "https://mdm.example.com:443",
|
||||
|
||||
@@ -61,6 +61,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
rosenpassEnabled := true
|
||||
rosenpassPermissive := true
|
||||
serverSSHAllowed := true
|
||||
serverVNCAllowed := true
|
||||
disableVNCApproval := true
|
||||
interfaceName := "utun100"
|
||||
wireguardPort := int64(51820)
|
||||
preSharedKey := "test-psk"
|
||||
@@ -85,6 +87,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
RosenpassEnabled: &rosenpassEnabled,
|
||||
RosenpassPermissive: &rosenpassPermissive,
|
||||
ServerSSHAllowed: &serverSSHAllowed,
|
||||
ServerVNCAllowed: &serverVNCAllowed,
|
||||
DisableVNCApproval: &disableVNCApproval,
|
||||
InterfaceName: &interfaceName,
|
||||
WireguardPort: &wireguardPort,
|
||||
OptionalPreSharedKey: &preSharedKey,
|
||||
@@ -128,6 +132,10 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
|
||||
require.NotNil(t, cfg.ServerSSHAllowed)
|
||||
require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed)
|
||||
require.NotNil(t, cfg.ServerVNCAllowed)
|
||||
require.Equal(t, serverVNCAllowed, *cfg.ServerVNCAllowed)
|
||||
require.NotNil(t, cfg.DisableVNCApproval)
|
||||
require.Equal(t, disableVNCApproval, *cfg.DisableVNCApproval)
|
||||
require.Equal(t, interfaceName, cfg.WgIface)
|
||||
require.Equal(t, int(wireguardPort), cfg.WgPort)
|
||||
require.Equal(t, preSharedKey, cfg.PreSharedKey)
|
||||
@@ -180,6 +188,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
|
||||
"RosenpassEnabled": true,
|
||||
"RosenpassPermissive": true,
|
||||
"ServerSSHAllowed": true,
|
||||
"ServerVNCAllowed": true,
|
||||
"DisableVNCApproval": true,
|
||||
"InterfaceName": true,
|
||||
"WireguardPort": true,
|
||||
"OptionalPreSharedKey": true,
|
||||
@@ -240,6 +250,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
|
||||
"enable-rosenpass": "RosenpassEnabled",
|
||||
"rosenpass-permissive": "RosenpassPermissive",
|
||||
"allow-server-ssh": "ServerSSHAllowed",
|
||||
"allow-server-vnc": "ServerVNCAllowed",
|
||||
"disable-vnc-approval": "DisableVNCApproval",
|
||||
"interface-name": "InterfaceName",
|
||||
"wireguard-port": "WireguardPort",
|
||||
"preshared-key": "OptionalPreSharedKey",
|
||||
|
||||
@@ -26,40 +26,51 @@ import (
|
||||
// daemon's SSH server into a root (or unauthenticated) shell.
|
||||
// - Enabling the SSH server at all is what makes the above reachable, and a
|
||||
// profile the caller owns is not a privilege they hold.
|
||||
// - While the SSH server is enabled, repointing the profile at another
|
||||
// management identity hands SSH authorization decisions, including which
|
||||
// keys and users are accepted, to whoever controls that identity. Changing
|
||||
// the management URL and deregistering the peer are both ways to do that.
|
||||
// - Enabling the VNC server exposes the console session, which on a
|
||||
// multi-user host belongs to another user, and disabling its approval
|
||||
// prompt removes that user's only say in it.
|
||||
// - While a remote-access server (SSH or VNC) is enabled, repointing the
|
||||
// profile at another management identity hands authorization decisions,
|
||||
// including which keys and users are accepted, to whoever controls that
|
||||
// identity. Changing the management URL and deregistering the peer are both
|
||||
// ways to do that.
|
||||
//
|
||||
// Everything else stays unauthenticated, so this is not an authorization model:
|
||||
// it only refuses the changes that would let a local user become root. A caller
|
||||
// whose identity cannot be established is refused as well.
|
||||
// it only refuses the changes that would let a local user become root, or reach
|
||||
// another user's desktop. A caller whose identity cannot be established is
|
||||
// refused as well.
|
||||
|
||||
// privilegedConfigChange is the subset of a config request that crosses the
|
||||
// user-to-root boundary. Fields are nil or empty when the request leaves them
|
||||
// untouched.
|
||||
type privilegedConfigChange struct {
|
||||
managementURL string
|
||||
serverSSHAllowed *bool
|
||||
enableSSHRoot *bool
|
||||
disableSSHAuth *bool
|
||||
managementURL string
|
||||
serverSSHAllowed *bool
|
||||
enableSSHRoot *bool
|
||||
disableSSHAuth *bool
|
||||
serverVNCAllowed *bool
|
||||
disableVNCApproval *bool
|
||||
}
|
||||
|
||||
func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange {
|
||||
return privilegedConfigChange{
|
||||
managementURL: msg.GetManagementUrl(),
|
||||
serverSSHAllowed: msg.ServerSSHAllowed,
|
||||
enableSSHRoot: msg.EnableSSHRoot,
|
||||
disableSSHAuth: msg.DisableSSHAuth,
|
||||
managementURL: msg.GetManagementUrl(),
|
||||
serverSSHAllowed: msg.ServerSSHAllowed,
|
||||
enableSSHRoot: msg.EnableSSHRoot,
|
||||
disableSSHAuth: msg.DisableSSHAuth,
|
||||
serverVNCAllowed: msg.ServerVNCAllowed,
|
||||
disableVNCApproval: msg.DisableVNCApproval,
|
||||
}
|
||||
}
|
||||
|
||||
func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange {
|
||||
return privilegedConfigChange{
|
||||
managementURL: msg.GetManagementUrl(),
|
||||
serverSSHAllowed: msg.ServerSSHAllowed,
|
||||
enableSSHRoot: msg.EnableSSHRoot,
|
||||
disableSSHAuth: msg.DisableSSHAuth,
|
||||
managementURL: msg.GetManagementUrl(),
|
||||
serverSSHAllowed: msg.ServerSSHAllowed,
|
||||
enableSSHRoot: msg.EnableSSHRoot,
|
||||
disableSSHAuth: msg.DisableSSHAuth,
|
||||
serverVNCAllowed: msg.ServerVNCAllowed,
|
||||
disableVNCApproval: msg.DisableVNCApproval,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,15 +94,21 @@ func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager
|
||||
return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh"))
|
||||
}
|
||||
|
||||
// Only guard the management binding while the SSH server is enabled: that is
|
||||
// when the management identity decides who may open a shell here.
|
||||
if !sshServerEnabled(stored) {
|
||||
if err := requirePrivilegeForVNCChange(ctx, stored, change); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only guard the management binding while a remote-access server is enabled:
|
||||
// that is when the management identity decides who may open a shell or reach
|
||||
// the desktop here.
|
||||
server, enabled := enabledRemoteAccessServer(stored)
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if change.managementURL != "" && !sameManagementURL(stored.ManagementURL, change.managementURL) {
|
||||
return denyPrivileged(ctx,
|
||||
"changing the management URL while the NetBird SSH server is enabled",
|
||||
fmt.Sprintf("changing the management URL while the NetBird %s server is enabled", server),
|
||||
ipcauth.UpCommand("-m "+change.managementURL))
|
||||
}
|
||||
|
||||
@@ -99,20 +116,21 @@ func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager
|
||||
}
|
||||
|
||||
// requirePrivilegeForDeregistration refuses to deregister the peer from the
|
||||
// management server when the caller is not privileged and the profile has the
|
||||
// SSH server enabled. Deregistering frees the peer's key to be registered
|
||||
// against another management identity, which is the same handover the
|
||||
// management server when the caller is not privileged and the profile has a
|
||||
// remote-access server enabled. Deregistering frees the peer's key to be
|
||||
// registered against another management identity, which is the same handover the
|
||||
// management URL check refuses.
|
||||
//
|
||||
// Callers that treat deregistration as best-effort (profile removal) continue
|
||||
// without it; callers that were asked to deregister surface the error.
|
||||
func requirePrivilegeForDeregistration(ctx context.Context, cfg *profilemanager.Config) error {
|
||||
if !sshServerEnabled(cfg) {
|
||||
server, enabled := enabledRemoteAccessServer(cfg)
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
return denyPrivileged(ctx,
|
||||
"deregistering this peer while the NetBird SSH server is enabled",
|
||||
fmt.Sprintf("deregistering this peer while the NetBird %s server is enabled", server),
|
||||
ipcauth.ElevatedCommand("netbird logout"))
|
||||
}
|
||||
|
||||
|
||||
58
client/server/vnc_gate.go
Normal file
58
client/server/vnc_gate.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// The VNC half of the privilege gate described in ssh_gate.go. The daemon runs
|
||||
// as root/LocalSystem and the VNC server it hosts attaches to the console
|
||||
// session, so both of these cross the user-to-root boundary:
|
||||
//
|
||||
// - Enabling the VNC server publishes the console desktop, keyboard and
|
||||
// mouse to whoever the account authorizes. On a multi-user host that
|
||||
// desktop belongs to another user, and the profile the caller owns is not a
|
||||
// privilege they hold over it.
|
||||
// - Disabling the approval prompt removes the console user's per-connection
|
||||
// consent, turning an authorized VNC session into a silent one.
|
||||
func requirePrivilegeForVNCChange(ctx context.Context, stored *profilemanager.Config, change privilegedConfigChange) error {
|
||||
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.DisableVNCApproval }), change.disableVNCApproval) {
|
||||
return denyPrivileged(ctx, "disabling VNC connection approval", ipcauth.UpCommand("--disable-vnc-approval"))
|
||||
}
|
||||
|
||||
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.ServerVNCAllowed }), change.serverVNCAllowed) {
|
||||
return denyPrivileged(ctx, "enabling the NetBird VNC server", ipcauth.UpCommand("--allow-server-vnc"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// vncServerEnabled reports whether the profile currently runs the VNC server.
|
||||
//
|
||||
// Unlike the SSH server, VNC defaults to off: the flag was introduced with the
|
||||
// server itself, so a nil value is a config written before VNC existed and
|
||||
// means the server is not running.
|
||||
func vncServerEnabled(cfg *profilemanager.Config) bool {
|
||||
if cfg == nil || cfg.ServerVNCAllowed == nil {
|
||||
return false
|
||||
}
|
||||
return *cfg.ServerVNCAllowed
|
||||
}
|
||||
|
||||
// enabledRemoteAccessServer names a remote-access server the profile currently
|
||||
// runs, and whether it runs any. It decides whether the management-binding and
|
||||
// deregistration guards apply, since either server hands authorization
|
||||
// decisions to the management identity the profile points at. SSH is reported
|
||||
// first when both are on: it is the more privileged of the two.
|
||||
func enabledRemoteAccessServer(cfg *profilemanager.Config) (string, bool) {
|
||||
switch {
|
||||
case sshServerEnabled(cfg):
|
||||
return "SSH", true
|
||||
case vncServerEnabled(cfg):
|
||||
return "VNC", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
178
client/server/vnc_gate_test.go
Normal file
178
client/server/vnc_gate_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
func TestRequirePrivilegeForConfigChange_VNCFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stored *profilemanager.Config
|
||||
change privilegedConfigChange
|
||||
privileged bool
|
||||
wantDeny bool
|
||||
}{
|
||||
{
|
||||
name: "enabling the vnc server unprivileged is refused",
|
||||
stored: &profilemanager.Config{ServerVNCAllowed: boolPtr(false)},
|
||||
change: privilegedConfigChange{serverVNCAllowed: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "enabling the vnc server as root is allowed",
|
||||
stored: &profilemanager.Config{ServerVNCAllowed: boolPtr(false)},
|
||||
change: privilegedConfigChange{serverVNCAllowed: boolPtr(true)},
|
||||
privileged: true,
|
||||
},
|
||||
{
|
||||
name: "restating an already enabled vnc server is not a change",
|
||||
stored: &profilemanager.Config{ServerVNCAllowed: boolPtr(true)},
|
||||
change: privilegedConfigChange{serverVNCAllowed: boolPtr(true)},
|
||||
},
|
||||
{
|
||||
name: "turning the vnc server off is not guarded",
|
||||
stored: &profilemanager.Config{ServerVNCAllowed: boolPtr(true)},
|
||||
change: privilegedConfigChange{serverVNCAllowed: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
// Unlike SSH, a nil flag means off: the flag shipped with the server.
|
||||
name: "a config written before vnc existed counts as off, so enabling is refused",
|
||||
stored: &profilemanager.Config{},
|
||||
change: privilegedConfigChange{serverVNCAllowed: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "a profile with no config yet counts as off, so enabling is refused",
|
||||
stored: nil,
|
||||
change: privilegedConfigChange{serverVNCAllowed: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "disabling the vnc approval prompt unprivileged is refused",
|
||||
stored: &profilemanager.Config{DisableVNCApproval: boolPtr(false)},
|
||||
change: privilegedConfigChange{disableVNCApproval: boolPtr(true)},
|
||||
wantDeny: true,
|
||||
},
|
||||
{
|
||||
name: "disabling the vnc approval prompt as root is allowed",
|
||||
stored: &profilemanager.Config{DisableVNCApproval: boolPtr(false)},
|
||||
change: privilegedConfigChange{disableVNCApproval: boolPtr(true)},
|
||||
privileged: true,
|
||||
},
|
||||
{
|
||||
name: "re-enabling the vnc approval prompt is not guarded",
|
||||
stored: &profilemanager.Config{DisableVNCApproval: boolPtr(true)},
|
||||
change: privilegedConfigChange{disableVNCApproval: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
name: "restating a disabled approval prompt is not a change",
|
||||
stored: &profilemanager.Config{DisableVNCApproval: boolPtr(true)},
|
||||
change: privilegedConfigChange{disableVNCApproval: boolPtr(true)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := userCtx()
|
||||
if tt.privileged {
|
||||
ctx = rootCtx()
|
||||
}
|
||||
err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change)
|
||||
if tt.wantDeny {
|
||||
assertDenied(t, err)
|
||||
return
|
||||
}
|
||||
assertAllowed(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The management binding and deregistration guards protect either remote-access
|
||||
// server, so the VNC server alone must arm them even with SSH off.
|
||||
func TestRequirePrivilegeForConfigChange_ManagementURLWithVNCOnly(t *testing.T) {
|
||||
vncOnly := &profilemanager.Config{
|
||||
ServerSSHAllowed: boolPtr(false),
|
||||
ServerVNCAllowed: boolPtr(true),
|
||||
ManagementURL: mustURL(t, "https://api.netbird.io:443"),
|
||||
DisableVNCApproval: boolPtr(false),
|
||||
}
|
||||
|
||||
err := requirePrivilegeForConfigChange(userCtx(), vncOnly,
|
||||
privilegedConfigChange{managementURL: "https://attacker.example.com:443"})
|
||||
assertDenied(t, err)
|
||||
|
||||
// The same move is the administrator's to make.
|
||||
assertAllowed(t, requirePrivilegeForConfigChange(rootCtx(), vncOnly,
|
||||
privilegedConfigChange{managementURL: "https://selfhosted.example.com:443"}))
|
||||
|
||||
// Restating the stored binding is not a change, so it is never refused.
|
||||
assertAllowed(t, requirePrivilegeForConfigChange(userCtx(), vncOnly,
|
||||
privilegedConfigChange{managementURL: "https://api.netbird.io"}))
|
||||
}
|
||||
|
||||
func TestRequirePrivilegeForDeregistration_VNCOnly(t *testing.T) {
|
||||
vncOnly := &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ServerVNCAllowed: boolPtr(true)}
|
||||
|
||||
assertDenied(t, requirePrivilegeForDeregistration(userCtx(), vncOnly))
|
||||
assertAllowed(t, requirePrivilegeForDeregistration(rootCtx(), vncOnly))
|
||||
|
||||
bothOff := &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ServerVNCAllowed: boolPtr(false)}
|
||||
assertAllowed(t, requirePrivilegeForDeregistration(userCtx(), bothOff))
|
||||
}
|
||||
|
||||
// enabledRemoteAccessServer names the server in the refusal, so the user is told
|
||||
// which one is holding the binding down. SSH wins when both are on: it is the
|
||||
// more privileged of the two.
|
||||
func TestEnabledRemoteAccessServer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *profilemanager.Config
|
||||
wantServer string
|
||||
wantOn bool
|
||||
}{
|
||||
{
|
||||
name: "ssh only",
|
||||
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ServerVNCAllowed: boolPtr(false)},
|
||||
wantServer: "SSH",
|
||||
wantOn: true,
|
||||
},
|
||||
{
|
||||
name: "vnc only",
|
||||
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ServerVNCAllowed: boolPtr(true)},
|
||||
wantServer: "VNC",
|
||||
wantOn: true,
|
||||
},
|
||||
{
|
||||
name: "both on reports ssh",
|
||||
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ServerVNCAllowed: boolPtr(true)},
|
||||
wantServer: "SSH",
|
||||
wantOn: true,
|
||||
},
|
||||
{
|
||||
name: "both off",
|
||||
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ServerVNCAllowed: boolPtr(false)},
|
||||
},
|
||||
{
|
||||
// A nil SSH flag means on (legacy configs), so it still arms the guard.
|
||||
name: "legacy config with no flags at all reports ssh",
|
||||
cfg: &profilemanager.Config{},
|
||||
wantServer: "SSH",
|
||||
wantOn: true,
|
||||
},
|
||||
{
|
||||
name: "no config",
|
||||
cfg: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server, on := enabledRemoteAccessServer(tt.cfg)
|
||||
if on != tt.wantOn || server != tt.wantServer {
|
||||
t.Fatalf("enabledRemoteAccessServer() = (%q, %v), want (%q, %v)", server, on, tt.wantServer, tt.wantOn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,10 @@ import (
|
||||
cryptossh "golang.org/x/crypto/ssh"
|
||||
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
"github.com/netbirdio/netbird/client/ssh/server"
|
||||
"github.com/netbirdio/netbird/client/ssh/testutil"
|
||||
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
"github.com/netbirdio/netbird/client/ssh/client"
|
||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||
"github.com/netbirdio/netbird/client/ssh/testutil"
|
||||
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ import (
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
sshauth "github.com/netbirdio/netbird/shared/sessionauth"
|
||||
"github.com/netbirdio/netbird/util/netrelay"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
@@ -197,6 +197,14 @@ type Config struct {
|
||||
|
||||
// HostKey is the SSH server host key in PEM format
|
||||
HostKeyPEM []byte
|
||||
|
||||
// NetstackNet, when non-nil, makes the SSH server listen via the
|
||||
// supplied userspace network stack instead of an OS socket.
|
||||
NetstackNet *netstack.Net
|
||||
|
||||
// NetworkValidation, when non-zero, restricts inbound connections to
|
||||
// peers inside the NetBird overlay defined by this WireGuard address.
|
||||
NetworkValidation wgaddr.Address
|
||||
}
|
||||
|
||||
// SessionInfo contains information about an active SSH session
|
||||
@@ -208,12 +216,15 @@ type SessionInfo struct {
|
||||
PortForwards []string
|
||||
}
|
||||
|
||||
// New creates an SSH server instance with the provided host key and optional JWT configuration
|
||||
// If jwtConfig is nil, JWT authentication is disabled
|
||||
// New creates an SSH server instance from the supplied Config. Fields are
|
||||
// read once at construction; mutating Config afterwards has no effect.
|
||||
// JWT == nil disables JWT authentication.
|
||||
func New(config *Config) *Server {
|
||||
s := &Server{
|
||||
mu: sync.RWMutex{},
|
||||
hostKeyPEM: config.HostKeyPEM,
|
||||
netstackNet: config.NetstackNet,
|
||||
wgAddress: config.NetworkValidation,
|
||||
sessions: make(map[sessionKey]*sessionState),
|
||||
pendingAuthJWT: make(map[authKey]string),
|
||||
remoteForwardListeners: make(map[forwardKey]net.Listener),
|
||||
@@ -434,20 +445,6 @@ func (s *Server) buildSessionInfo(state *sessionState) SessionInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// SetNetstackNet sets the netstack network for userspace networking
|
||||
func (s *Server) SetNetstackNet(net *netstack.Net) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.netstackNet = net
|
||||
}
|
||||
|
||||
// SetNetworkValidation configures network-based connection filtering
|
||||
func (s *Server) SetNetworkValidation(addr wgaddr.Address) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.wgAddress = addr
|
||||
}
|
||||
|
||||
// UpdateSSHAuth updates the SSH fine-grained access control configuration
|
||||
// This should be called when network map updates include new SSH auth configuration
|
||||
func (s *Server) UpdateSSHAuth(config *sshauth.Config) {
|
||||
|
||||
@@ -136,6 +136,19 @@ type SSHServerStateOutput struct {
|
||||
Sessions []SSHSessionOutput `json:"sessions" yaml:"sessions"`
|
||||
}
|
||||
|
||||
type VNCSessionOutput struct {
|
||||
RemoteAddress string `json:"remoteAddress" yaml:"remoteAddress"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
Username string `json:"username,omitempty" yaml:"username,omitempty"`
|
||||
UserID string `json:"userID,omitempty" yaml:"userID,omitempty"`
|
||||
Initiator string `json:"initiator,omitempty" yaml:"initiator,omitempty"`
|
||||
}
|
||||
|
||||
type VNCServerStateOutput struct {
|
||||
Enabled bool `json:"enabled" yaml:"enabled"`
|
||||
Sessions []VNCSessionOutput `json:"sessions" yaml:"sessions"`
|
||||
}
|
||||
|
||||
type OutputOverview struct {
|
||||
Peers PeersStateOutput `json:"peers" yaml:"peers"`
|
||||
CliVersion string `json:"cliVersion" yaml:"cliVersion"`
|
||||
@@ -159,6 +172,7 @@ type OutputOverview struct {
|
||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"`
|
||||
ProfileName string `json:"profileName" yaml:"profileName"`
|
||||
SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"`
|
||||
VNCServerState VNCServerStateOutput `json:"vncServer" yaml:"vncServer"`
|
||||
// SessionExpiresAt is the absolute UTC instant at which the peer's SSO
|
||||
// session expires. nil when the peer is not SSO-tracked or login
|
||||
// expiration is disabled. Pointer (rather than zero-value time.Time) so
|
||||
@@ -184,6 +198,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
|
||||
relayOverview := mapRelays(pbFullStatus.GetRelays())
|
||||
sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState())
|
||||
vncServerOverview := mapVNCServer(pbFullStatus.GetVncServerState())
|
||||
peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter)
|
||||
|
||||
overview := OutputOverview{
|
||||
@@ -209,6 +224,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(),
|
||||
ProfileName: opts.ProfileName,
|
||||
SSHServerState: sshServerOverview,
|
||||
VNCServerState: vncServerOverview,
|
||||
}
|
||||
if !opts.SessionExpiresAt.IsZero() {
|
||||
t := opts.SessionExpiresAt
|
||||
@@ -294,6 +310,26 @@ func mapSSHServer(sshServerState *proto.SSHServerState) SSHServerStateOutput {
|
||||
}
|
||||
}
|
||||
|
||||
func mapVNCServer(state *proto.VNCServerState) VNCServerStateOutput {
|
||||
if state == nil {
|
||||
return VNCServerStateOutput{Sessions: []VNCSessionOutput{}}
|
||||
}
|
||||
sessions := make([]VNCSessionOutput, 0, len(state.GetSessions()))
|
||||
for _, sess := range state.GetSessions() {
|
||||
sessions = append(sessions, VNCSessionOutput{
|
||||
RemoteAddress: sess.GetRemoteAddress(),
|
||||
Mode: sess.GetMode(),
|
||||
Username: sess.GetUsername(),
|
||||
UserID: sess.GetUserID(),
|
||||
Initiator: sess.GetInitiator(),
|
||||
})
|
||||
}
|
||||
return VNCServerStateOutput{
|
||||
Enabled: state.GetEnabled(),
|
||||
Sessions: sessions,
|
||||
}
|
||||
}
|
||||
|
||||
func mapPeers(
|
||||
peers []*proto.PeerState,
|
||||
statusFilter string,
|
||||
@@ -558,6 +594,26 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
}
|
||||
}
|
||||
|
||||
vncServerStatus := "Disabled"
|
||||
if o.VNCServerState.Enabled {
|
||||
vncSessionCount := len(o.VNCServerState.Sessions)
|
||||
if vncSessionCount > 0 {
|
||||
sessionWord := "session"
|
||||
if vncSessionCount > 1 {
|
||||
sessionWord = "sessions"
|
||||
}
|
||||
vncServerStatus = fmt.Sprintf("Enabled (%d active %s)", vncSessionCount, sessionWord)
|
||||
} else {
|
||||
vncServerStatus = "Enabled"
|
||||
}
|
||||
|
||||
if showSSHSessions && vncSessionCount > 0 {
|
||||
for _, sess := range o.VNCServerState.Sessions {
|
||||
vncServerStatus += "\n " + formatVNCSessionLine(sess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
|
||||
|
||||
var sessionExpiryString string
|
||||
@@ -613,6 +669,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
"Quantum resistance: %s\n"+
|
||||
"Lazy connection: %s\n"+
|
||||
"SSH Server: %s\n"+
|
||||
"VNC Server: %s\n"+
|
||||
"Networks: %s\n"+
|
||||
"%s"+
|
||||
"%s"+
|
||||
@@ -633,6 +690,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
rosenpassEnabledStatus,
|
||||
lazyConnectionEnabledStatus,
|
||||
sshServerStatus,
|
||||
vncServerStatus,
|
||||
networks,
|
||||
forwardingRulesString,
|
||||
sessionExpiryString,
|
||||
@@ -995,6 +1053,26 @@ func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) {
|
||||
}
|
||||
}
|
||||
|
||||
// formatVNCSessionLine renders a single VNC session row for the detailed
|
||||
// status output. The leading slot identifies the initiator (display name
|
||||
// when known, hashed UserID otherwise); the post-arrow slot is the OS
|
||||
// user the session targets and is omitted in attach mode where the
|
||||
// destination is the current console user (unknown to the daemon).
|
||||
func formatVNCSessionLine(sess VNCSessionOutput) string {
|
||||
who := sess.Initiator
|
||||
if who == "" {
|
||||
who = sess.UserID
|
||||
}
|
||||
prefix := sess.RemoteAddress
|
||||
if who != "" {
|
||||
prefix = fmt.Sprintf("%s@%s", who, sess.RemoteAddress)
|
||||
}
|
||||
if sess.Username != "" {
|
||||
return fmt.Sprintf("[%s -> %s] mode=%s", prefix, sess.Username, sess.Mode)
|
||||
}
|
||||
return fmt.Sprintf("[%s] mode=%s", prefix, sess.Mode)
|
||||
}
|
||||
|
||||
func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, peer := range overview.Peers.Details {
|
||||
peer := peer
|
||||
@@ -1015,6 +1093,19 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
overview.Relays.Details[i] = detail
|
||||
}
|
||||
|
||||
anonymizeNSServerGroups(a, overview)
|
||||
|
||||
for i, route := range overview.Networks {
|
||||
overview.Networks[i] = a.AnonymizeRoute(route)
|
||||
}
|
||||
|
||||
overview.FQDN = a.AnonymizeDomain(overview.FQDN)
|
||||
|
||||
anonymizeEvents(a, overview)
|
||||
anonymizeServerSessions(a, overview)
|
||||
}
|
||||
|
||||
func anonymizeNSServerGroups(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, nsGroup := range overview.NSServerGroups {
|
||||
for j, domain := range nsGroup.Domains {
|
||||
overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomain(domain)
|
||||
@@ -1026,13 +1117,9 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, route := range overview.Networks {
|
||||
overview.Networks[i] = a.AnonymizeRoute(route)
|
||||
}
|
||||
|
||||
overview.FQDN = a.AnonymizeDomain(overview.FQDN)
|
||||
|
||||
func anonymizeEvents(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, event := range overview.Events {
|
||||
overview.Events[i].Message = a.AnonymizeString(event.Message)
|
||||
overview.Events[i].UserMessage = a.AnonymizeString(event.UserMessage)
|
||||
@@ -1041,15 +1128,26 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
event.Metadata[k] = a.AnonymizeString(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func anonymizeRemoteAddress(a *anonymize.Anonymizer, addr string) string {
|
||||
if host, port, err := net.SplitHostPort(addr); err == nil {
|
||||
return fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port)
|
||||
}
|
||||
return a.AnonymizeIPString(addr)
|
||||
}
|
||||
|
||||
func anonymizeServerSessions(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, session := range overview.SSHServerState.Sessions {
|
||||
if host, port, err := net.SplitHostPort(session.RemoteAddress); err == nil {
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port)
|
||||
} else {
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = a.AnonymizeIPString(session.RemoteAddress)
|
||||
}
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, session.RemoteAddress)
|
||||
overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command)
|
||||
}
|
||||
for i, sess := range overview.VNCServerState.Sessions {
|
||||
overview.VNCServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, sess.RemoteAddress)
|
||||
overview.VNCServerState.Sessions[i].Username = a.AnonymizeString(sess.Username)
|
||||
overview.VNCServerState.Sessions[i].UserID = a.AnonymizeString(sess.UserID)
|
||||
overview.VNCServerState.Sessions[i].Initiator = a.AnonymizeString(sess.Initiator)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatRemainingDuration renders a time.Duration for the "Session expires"
|
||||
|
||||
@@ -242,6 +242,10 @@ var overview = OutputOverview{
|
||||
Enabled: false,
|
||||
Sessions: []SSHSessionOutput{},
|
||||
},
|
||||
VNCServerState: VNCServerStateOutput{
|
||||
Enabled: false,
|
||||
Sessions: []VNCSessionOutput{},
|
||||
},
|
||||
}
|
||||
|
||||
func TestConversionFromFullStatusToOutputOverview(t *testing.T) {
|
||||
@@ -407,6 +411,10 @@ func TestParsingToJSON(t *testing.T) {
|
||||
"sshServer":{
|
||||
"enabled":false,
|
||||
"sessions":[]
|
||||
},
|
||||
"vncServer":{
|
||||
"enabled":false,
|
||||
"sessions":[]
|
||||
}
|
||||
}`
|
||||
// @formatter:on
|
||||
@@ -517,6 +525,9 @@ profileName: ""
|
||||
sshServer:
|
||||
enabled: false
|
||||
sessions: []
|
||||
vncServer:
|
||||
enabled: false
|
||||
sessions: []
|
||||
`
|
||||
|
||||
assert.Equal(t, expectedYAML, yaml)
|
||||
@@ -587,6 +598,7 @@ Wireguard port: %d
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
VNC Server: Disabled
|
||||
Networks: 10.10.0.0/24
|
||||
Peers count: 2/2 Connected
|
||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
|
||||
@@ -613,6 +625,7 @@ Wireguard port: 51820
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
VNC Server: Disabled
|
||||
Networks: 10.10.0.0/24
|
||||
Peers count: 2/2 Connected
|
||||
`
|
||||
|
||||
@@ -65,6 +65,7 @@ type Info struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed bool
|
||||
ServerVNCAllowed bool
|
||||
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -86,6 +87,7 @@ type Info struct {
|
||||
func (i *Info) SetFlags(
|
||||
rosenpassEnabled, rosenpassPermissive bool,
|
||||
serverSSHAllowed *bool,
|
||||
serverVNCAllowed *bool,
|
||||
disableClientRoutes, disableServerRoutes,
|
||||
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int,
|
||||
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
|
||||
@@ -96,6 +98,9 @@ func (i *Info) SetFlags(
|
||||
if serverSSHAllowed != nil {
|
||||
i.ServerSSHAllowed = *serverSSHAllowed
|
||||
}
|
||||
if serverVNCAllowed != nil {
|
||||
i.ServerVNCAllowed = *serverVNCAllowed
|
||||
}
|
||||
|
||||
i.DisableClientRoutes = disableClientRoutes
|
||||
i.DisableServerRoutes = disableServerRoutes
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
|
||||
import "./globals.css";
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx";
|
||||
import ApprovalDialog from "@/modules/approval/ApprovalDialog.tsx";
|
||||
import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
|
||||
import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx";
|
||||
import ErrorDialog from "@/modules/error/ErrorDialog.tsx";
|
||||
@@ -48,6 +49,7 @@ Promise.all([
|
||||
path={"session-expiration"}
|
||||
element={<SessionExpirationDialog />}
|
||||
/>
|
||||
<Route path={"approval"} element={<ApprovalDialog />} />
|
||||
<Route path={"welcome"} element={<WelcomeDialog />} />
|
||||
<Route path={"error"} element={<ErrorDialog />} />
|
||||
</Route>
|
||||
|
||||
170
client/ui/frontend/src/modules/approval/ApprovalDialog.tsx
Normal file
170
client/ui/frontend/src/modules/approval/ApprovalDialog.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { MonitorIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Approval, WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const WINDOW_WIDTH = 360;
|
||||
// Fallback window so a missing/unparseable expires_at can't leave the prompt open forever.
|
||||
const FALLBACK_SECONDS = 13;
|
||||
|
||||
// shortFingerprint groups a hex key as XXXX-XXXX-XXXX-XXXX (16 chars). Mirrors the
|
||||
// daemon's approval.ShortKeyFingerprint so the value matches an out-of-band reference.
|
||||
function shortFingerprint(hexKey: string): string {
|
||||
if (hexKey.length < 8) return "";
|
||||
const src = hexKey.slice(0, 16);
|
||||
return src.match(/.{1,4}/g)?.join("-") ?? src;
|
||||
}
|
||||
|
||||
type Row = { label: string; value: string; mono?: boolean };
|
||||
|
||||
export default function ApprovalDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const [params] = useSearchParams();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const requestID = params.get("request_id") ?? "";
|
||||
const kind = params.get("kind") ?? "";
|
||||
const initiator = params.get("initiator") ?? "";
|
||||
const peerName = params.get("peer_name") ?? "";
|
||||
const sourceIP = params.get("source_ip") ?? "";
|
||||
const username = params.get("username") ?? "";
|
||||
const peerPubKey = params.get("peer_pubkey") ?? "";
|
||||
const expiresAt = params.get("expires_at") ?? "";
|
||||
|
||||
const deadline = useMemo(() => {
|
||||
const parsed = Date.parse(expiresAt);
|
||||
return Number.isFinite(parsed) ? parsed : Date.now() + FALLBACK_SECONDS * 1000;
|
||||
}, [expiresAt]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
switch (kind) {
|
||||
case "vnc":
|
||||
return t("approval.title.vnc");
|
||||
case "ssh":
|
||||
return t("approval.title.ssh");
|
||||
default:
|
||||
return t("approval.title.default");
|
||||
}
|
||||
}, [kind, t]);
|
||||
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
const out: Row[] = [];
|
||||
// The display name is dashboard-supplied and not cryptographically
|
||||
// asserted; the key fingerprint below IS, so show both.
|
||||
if (initiator) out.push({ label: t("approval.field.user"), value: initiator });
|
||||
const fp = shortFingerprint(peerPubKey);
|
||||
if (fp) out.push({ label: t("approval.field.keyFingerprint"), value: fp, mono: true });
|
||||
if (peerName) out.push({ label: t("approval.field.peer"), value: peerName });
|
||||
if (sourceIP && sourceIP !== peerName)
|
||||
out.push({ label: t("approval.field.sourceIp"), value: sourceIP, mono: true });
|
||||
if (username) out.push({ label: t("approval.field.osUser"), value: username });
|
||||
return out;
|
||||
}, [initiator, peerPubKey, peerName, sourceIP, username, t]);
|
||||
|
||||
const respond = useCallback(
|
||||
async (accept: boolean, viewOnly: boolean) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (requestID) {
|
||||
await Approval.Respond(requestID, accept, viewOnly);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("respond approval failed", e);
|
||||
} finally {
|
||||
WindowManager.CloseApproval().catch(console.error);
|
||||
}
|
||||
},
|
||||
[busy, requestID],
|
||||
);
|
||||
|
||||
const secondsLeft = () => Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
||||
const [remaining, setRemaining] = useState(secondsLeft);
|
||||
const closedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const id = globalThis.setInterval(() => {
|
||||
const left = secondsLeft();
|
||||
setRemaining(left);
|
||||
// On the deadline the daemon auto-denies; just close the prompt.
|
||||
if (left <= 0 && !closedRef.current) {
|
||||
closedRef.current = true;
|
||||
WindowManager.CloseApproval().catch(console.error);
|
||||
}
|
||||
}, 1000);
|
||||
return () => globalThis.clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deadline]);
|
||||
|
||||
const showViewOnly = kind === "vnc";
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-approval-title"}>
|
||||
<SquareIcon icon={MonitorIcon} />
|
||||
|
||||
<DialogHeading id={"nb-approval-title"}>{title}</DialogHeading>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<dl className={"w-full space-y-1 text-left text-sm"}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className={"flex justify-between gap-4"}>
|
||||
<dt className={"shrink-0 text-nb-gray-400"}>{row.label}</dt>
|
||||
<dd
|
||||
className={`min-w-0 truncate text-nb-gray-100 ${
|
||||
row.mono ? "font-mono" : ""
|
||||
}`}
|
||||
title={row.value}
|
||||
>
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className={"text-sm tabular-nums text-nb-gray-400"} aria-live={"polite"}>
|
||||
{t("approval.countdown", { seconds: remaining })}
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={() => respond(true, false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("approval.action.allow")}
|
||||
</Button>
|
||||
{showViewOnly && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={() => respond(true, true)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("approval.action.allowViewOnly")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={"danger"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={() => respond(false, false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("approval.action.deny")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
// ActiveSessionIndicator marks that someone is attached to this machine over
|
||||
// VNC right now, and warns that disconnecting ends that session — which may be
|
||||
// the session the person reading it is using.
|
||||
//
|
||||
// An alert glyph rather than an info one: this is not neutral context, it is a
|
||||
// state that changes what the disconnect button does to you.
|
||||
//
|
||||
// Only a warning, never a block. Input injected by the VNC agent is
|
||||
// indistinguishable from local input, so we cannot tell whether this UI is being
|
||||
// driven remotely, and the owner may want to disconnect on purpose either way.
|
||||
//
|
||||
// Renders nothing when no session is attached.
|
||||
export function ActiveSessionIndicator({ className }: { className?: string }): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const sessions = status?.vncSessions ?? [];
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
// The initiator is the dashboard user who started the session, which is more
|
||||
// use than a source address. Absent when the session carries no identity.
|
||||
const who = sessions
|
||||
.map((s) => s.initiator)
|
||||
.filter((name): name is string => !!name)
|
||||
.join(", ");
|
||||
|
||||
const message = who
|
||||
? t("connect.activeSession.tooltipNamed", { sessionCount: sessions.length, who })
|
||||
: t("connect.activeSession.tooltip", { sessionCount: sessions.length });
|
||||
|
||||
return (
|
||||
<Tooltip content={<span className={"block max-w-64"}>{message}</span>}>
|
||||
<span
|
||||
role={"status"}
|
||||
aria-label={message}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border border-yellow-700/50 bg-yellow-900/20 px-2 py-0.5 text-yellow-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CircleAlert size={12} aria-hidden={true} />
|
||||
<span className={"text-[0.7rem] leading-none"}>
|
||||
{t("connect.activeSession.badge")}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
import { ActiveSessionIndicator } from "@/modules/main/ActiveSessionIndicator.tsx";
|
||||
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
@@ -272,6 +273,9 @@ export const MainConnectionStatusSwitch = () => {
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
<LocalIpLine ip={ip} ipv6={ipv6} show={show} />
|
||||
{connState === ConnectionState.Connected && (
|
||||
<ActiveSessionIndicator className={"mt-3"} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
86
client/ui/frontend/src/modules/settings/PrivilegeGuard.tsx
Normal file
86
client/ui/frontend/src/modules/settings/PrivilegeGuard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
||||
import { Privilege } from "@bindings/services/models.js";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export type GuardedControl = {
|
||||
disabled: boolean;
|
||||
hint: ReactNode;
|
||||
};
|
||||
|
||||
// useGuardedControl returns a guard for the settings controls the daemon
|
||||
// restricts to root/administrator: enabling a remote-access server, or removing
|
||||
// one of its safeguards.
|
||||
//
|
||||
// The daemon restricts only the direction that hands out access from a process
|
||||
// running as root. So for an unprivileged user a guarded control is either
|
||||
// unavailable (it is off and only they could turn it on) or a one-way switch (it
|
||||
// is on, they may turn it off, but not back on) — say which, either way.
|
||||
//
|
||||
// A null privilege means we could not determine it: leave the control alone
|
||||
// rather than greying it out with nothing to explain why. The daemon enforces
|
||||
// this regardless, and a rejected save reports its own guidance.
|
||||
export const useGuardedControl = () => {
|
||||
const privilege = usePrivilege();
|
||||
|
||||
return (
|
||||
guardedDirectionActive: boolean,
|
||||
command: (p: Privilege) => string,
|
||||
// inverted marks a control whose guarded direction is switching it off,
|
||||
// so the one-way warning has to read the other way round.
|
||||
inverted = false,
|
||||
): GuardedControl => {
|
||||
if (!privilege || privilege.privileged) {
|
||||
return { disabled: false, hint: undefined };
|
||||
}
|
||||
const hint = (
|
||||
<PrivilegeHint
|
||||
actor={privilege.actor}
|
||||
command={command(privilege)}
|
||||
oneWay={guardedDirectionActive}
|
||||
inverted={inverted}
|
||||
/>
|
||||
);
|
||||
return { disabled: !guardedDirectionActive, hint };
|
||||
};
|
||||
};
|
||||
|
||||
// PrivilegeHint explains what an unprivileged user can and cannot do with a
|
||||
// guarded control, and offers the command that does it with the privileges the
|
||||
// daemon requires. oneWay covers the control being in the guarded state already:
|
||||
// switching it back is the part that needs privileges.
|
||||
export function PrivilegeHint({
|
||||
actor,
|
||||
command,
|
||||
oneWay,
|
||||
inverted,
|
||||
}: {
|
||||
actor: string;
|
||||
command: string;
|
||||
oneWay: boolean;
|
||||
inverted: boolean;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
if (!command) return null;
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{!oneWay
|
||||
? t("settings.privilege.hint", { actor })
|
||||
: inverted
|
||||
? t("settings.privilege.oneWayInverted", { actor })
|
||||
: t("settings.privilege.oneWay", { actor })}
|
||||
</span>
|
||||
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
|
||||
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BoltIcon,
|
||||
InfoIcon,
|
||||
LifeBuoyIcon,
|
||||
MonitorIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
SlidersHorizontalIcon,
|
||||
@@ -20,6 +21,7 @@ export const SettingsNavigation = () => {
|
||||
const { updateAvailable } = useClientVersion();
|
||||
const { mdm, features } = useRestrictions();
|
||||
const showSsh = mdm.allowServerSSH ?? !features.disableUpdateSettings;
|
||||
const showVnc = mdm.allowServerVNC ?? !features.disableUpdateSettings;
|
||||
|
||||
const aboutAdornment = updateAvailable ? (
|
||||
<Tooltip content={t("settings.tabs.updateAvailable")} side={"right"}>
|
||||
@@ -63,6 +65,13 @@ export const SettingsNavigation = () => {
|
||||
title={t("settings.tabs.ssh")}
|
||||
/>
|
||||
)}
|
||||
{showVnc && (
|
||||
<VerticalTabs.Trigger
|
||||
value={"vnc"}
|
||||
icon={MonitorIcon}
|
||||
title={t("settings.tabs.vnc")}
|
||||
/>
|
||||
)}
|
||||
{!features.disableUpdateSettings && (
|
||||
<VerticalTabs.Trigger
|
||||
value={"advanced"}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
||||
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
||||
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
|
||||
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
|
||||
import { SettingsVNC } from "@/modules/settings/SettingsVNC.tsx";
|
||||
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
||||
@@ -26,6 +27,7 @@ const enum Tab {
|
||||
Security = "security",
|
||||
Profiles = "profiles",
|
||||
SSH = "ssh",
|
||||
VNC = "vnc",
|
||||
Advanced = "advanced",
|
||||
Troubleshooting = "troubleshooting",
|
||||
About = "about",
|
||||
@@ -37,6 +39,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
|
||||
[Tab.Security]: <SettingsSecurity />,
|
||||
[Tab.Profiles]: <ProfilesTab />,
|
||||
[Tab.SSH]: <SettingsSSH />,
|
||||
[Tab.VNC]: <SettingsVNC />,
|
||||
[Tab.Advanced]: <SettingsAdvanced />,
|
||||
[Tab.Troubleshooting]: <SettingsTroubleshooting />,
|
||||
[Tab.About]: <SettingsAbout />,
|
||||
@@ -55,12 +58,18 @@ export const SettingsPage = () => {
|
||||
[Tab.Security]: editable,
|
||||
[Tab.Profiles]: !features.disableProfiles,
|
||||
[Tab.SSH]: mdm.allowServerSSH ?? editable,
|
||||
[Tab.VNC]: mdm.allowServerVNC ?? editable,
|
||||
[Tab.Advanced]: editable,
|
||||
[Tab.Troubleshooting]: true,
|
||||
[Tab.About]: true,
|
||||
};
|
||||
return (Object.keys(visibility) as Tab[]).filter((t) => visibility[t]);
|
||||
}, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]);
|
||||
}, [
|
||||
features.disableUpdateSettings,
|
||||
features.disableProfiles,
|
||||
mdm.allowServerSSH,
|
||||
mdm.allowServerVNC,
|
||||
]);
|
||||
|
||||
const defaultTab = visibleTabs[0];
|
||||
const [active, setActive] = useState<string>(() => navState?.tab ?? defaultTab);
|
||||
|
||||
@@ -1,51 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useGuardedControl } from "@/modules/settings/PrivilegeGuard.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
||||
import { Privilege } from "@bindings/services/models.js";
|
||||
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
|
||||
import { type ChangeEvent, useEffect, useId, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const privilege = usePrivilege();
|
||||
const guarded = useGuardedControl();
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
|
||||
// The daemon restricts only the direction that hands out shells from a process
|
||||
// running as root. So for an unprivileged user a guarded control is either
|
||||
// unavailable (it is off and only they could turn it on) or a one-way switch
|
||||
// (it is on, they may turn it off, but not back on) — say which, either way.
|
||||
//
|
||||
// A null privilege means we could not determine it: leave the control alone
|
||||
// rather than greying it out with nothing to explain why. The daemon enforces
|
||||
// this regardless, and a rejected save reports its own guidance.
|
||||
const guarded = (
|
||||
guardedDirectionActive: boolean,
|
||||
command: (p: Privilege) => string,
|
||||
// inverted marks a control whose guarded direction is switching it off, so
|
||||
// the one-way warning has to read the other way round.
|
||||
inverted = false,
|
||||
) => {
|
||||
if (!privilege || privilege.privileged) {
|
||||
return { disabled: false, hint: undefined };
|
||||
}
|
||||
const hint = (
|
||||
<PrivilegeHint
|
||||
actor={privilege.actor}
|
||||
command={command(privilege)}
|
||||
oneWay={guardedDirectionActive}
|
||||
inverted={inverted}
|
||||
/>
|
||||
);
|
||||
return { disabled: !guardedDirectionActive, hint };
|
||||
};
|
||||
|
||||
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
|
||||
const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot);
|
||||
// Inverted control: the guarded direction is switching authentication off, so
|
||||
@@ -162,42 +130,3 @@ export function SettingsSSH() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// PrivilegeHint explains what an unprivileged user can and cannot do with a
|
||||
// guarded control, and offers the command that does it with the privileges the
|
||||
// daemon requires. oneWay covers the control being in the guarded state already:
|
||||
// switching it back is the part that needs privileges.
|
||||
function PrivilegeHint({
|
||||
actor,
|
||||
command,
|
||||
oneWay,
|
||||
inverted,
|
||||
}: {
|
||||
actor: string;
|
||||
command: string;
|
||||
oneWay: boolean;
|
||||
inverted: boolean;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
if (!command) return null;
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{!oneWay
|
||||
? t("settings.ssh.privilege.hint", { actor })
|
||||
: inverted
|
||||
? t("settings.ssh.privilege.oneWayInverted", { actor })
|
||||
: t("settings.ssh.privilege.oneWay", { actor })}
|
||||
</span>
|
||||
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
|
||||
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
51
client/ui/frontend/src/modules/settings/SettingsVNC.tsx
Normal file
51
client/ui/frontend/src/modules/settings/SettingsVNC.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useGuardedControl } from "@/modules/settings/PrivilegeGuard.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
|
||||
export function SettingsVNC() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const { mdm } = useRestrictions();
|
||||
const guarded = useGuardedControl();
|
||||
const isVNCServerEnabled = config.serverVncAllowed;
|
||||
const vncServerManaged = mdm.allowServerVNC != null;
|
||||
|
||||
const vncServer = guarded(config.serverVncAllowed, (p) => p.allowVncServer);
|
||||
// Inverted control: the guarded direction is switching the approval prompt
|
||||
// off, so the already-disabled state is the one-way one.
|
||||
const vncApproval = guarded(config.disableVncApproval, (p) => p.disableVncApproval, true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={t("settings.vnc.section.server")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.serverVncAllowed}
|
||||
onChange={(v) => setField("serverVncAllowed", v)}
|
||||
label={t("settings.vnc.server.label")}
|
||||
helpText={t("settings.vnc.server.help")}
|
||||
disabled={vncServerManaged || vncServer.disabled}
|
||||
/>
|
||||
{!vncServerManaged && vncServer.hint}
|
||||
</SectionGroup>
|
||||
|
||||
{!mdm.disableVNCApproval && (
|
||||
<SectionGroup
|
||||
title={t("settings.vnc.section.approval")}
|
||||
disabled={!isVNCServerEnabled}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableVncApproval}
|
||||
onChange={(v) => setField("disableVncApproval", !v)}
|
||||
label={t("settings.vnc.approval.label")}
|
||||
helpText={t("settings.vnc.approval.help")}
|
||||
disabled={vncApproval.disabled}
|
||||
/>
|
||||
{vncApproval.hint}
|
||||
</SectionGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1330,5 +1330,65 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Vorgang fehlgeschlagen."
|
||||
},
|
||||
"settings.tabs.vnc": {
|
||||
"message": "VNC"
|
||||
},
|
||||
"settings.vnc.section.server": {
|
||||
"message": "Server"
|
||||
},
|
||||
"settings.vnc.section.approval": {
|
||||
"message": "Genehmigung"
|
||||
},
|
||||
"settings.vnc.server.label": {
|
||||
"message": "VNC-Server aktivieren"
|
||||
},
|
||||
"settings.vnc.server.help": {
|
||||
"message": "Den NetBird-VNC-Server auf diesem Host ausführen, damit autorisierte Peers den Bildschirm ansehen oder steuern können."
|
||||
},
|
||||
"settings.vnc.approval.label": {
|
||||
"message": "Verbindungsgenehmigung erforderlich"
|
||||
},
|
||||
"settings.vnc.approval.help": {
|
||||
"message": "Auf diesem Host eine Aufforderung anzeigen, die bestätigt werden muss, bevor eine eingehende VNC-Verbindung zugelassen wird."
|
||||
},
|
||||
"window.title.approval": {
|
||||
"message": "Verbindungsanfrage"
|
||||
},
|
||||
"approval.title.vnc": {
|
||||
"message": "VNC-Verbindung zulassen?"
|
||||
},
|
||||
"approval.title.ssh": {
|
||||
"message": "SSH-Verbindung zulassen?"
|
||||
},
|
||||
"approval.title.default": {
|
||||
"message": "Eingehende Verbindung zulassen?"
|
||||
},
|
||||
"approval.field.user": {
|
||||
"message": "Von Benutzer"
|
||||
},
|
||||
"approval.field.keyFingerprint": {
|
||||
"message": "Schlüssel-Fingerabdruck"
|
||||
},
|
||||
"approval.field.peer": {
|
||||
"message": "Über Peer"
|
||||
},
|
||||
"approval.field.sourceIp": {
|
||||
"message": "Quell-IP"
|
||||
},
|
||||
"approval.field.osUser": {
|
||||
"message": "Betriebssystem-Benutzer"
|
||||
},
|
||||
"approval.countdown": {
|
||||
"message": "Automatische Ablehnung in {seconds}s"
|
||||
},
|
||||
"approval.action.allow": {
|
||||
"message": "Zulassen"
|
||||
},
|
||||
"approval.action.allowViewOnly": {
|
||||
"message": "Zulassen (nur ansehen)"
|
||||
},
|
||||
"approval.action.deny": {
|
||||
"message": "Ablehnen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,6 +683,10 @@
|
||||
"message": "SSH",
|
||||
"description": "Settings tab label: SSH. Acronym — keep as-is."
|
||||
},
|
||||
"settings.tabs.vnc": {
|
||||
"message": "VNC",
|
||||
"description": "Settings tab label: VNC. Acronym — keep as-is."
|
||||
},
|
||||
"settings.tabs.advanced": {
|
||||
"message": "Advanced",
|
||||
"description": "Settings tab label: Advanced. Keep short."
|
||||
@@ -951,6 +955,30 @@
|
||||
"message": "Second(s)",
|
||||
"description": "Unit suffix shown after the JWT TTL number field. The '(s)' marks an optional plural."
|
||||
},
|
||||
"settings.vnc.section.server": {
|
||||
"message": "Server",
|
||||
"description": "Section heading: Server (VNC settings)."
|
||||
},
|
||||
"settings.vnc.section.approval": {
|
||||
"message": "Approval",
|
||||
"description": "Section heading: Approval (VNC connection approval settings)."
|
||||
},
|
||||
"settings.vnc.server.label": {
|
||||
"message": "Enable VNC Server",
|
||||
"description": "Toggle label: enable the embedded VNC server."
|
||||
},
|
||||
"settings.vnc.server.help": {
|
||||
"message": "Run the NetBird VNC server on this host so authorized peers can view or control its screen.",
|
||||
"description": "Helper text for the VNC server toggle."
|
||||
},
|
||||
"settings.vnc.approval.label": {
|
||||
"message": "Require Connection Approval",
|
||||
"description": "Toggle label: prompt for approval before each inbound VNC connection."
|
||||
},
|
||||
"settings.vnc.approval.help": {
|
||||
"message": "Show a prompt on this host that must be accepted before an incoming VNC connection is allowed.",
|
||||
"description": "Helper text for the VNC connection-approval toggle."
|
||||
},
|
||||
"settings.advanced.section.interface": {
|
||||
"message": "Interface",
|
||||
"description": "Section heading: Interface (network-interface settings)."
|
||||
@@ -1363,6 +1391,58 @@
|
||||
"message": "Session Expiring",
|
||||
"description": "OS window-chrome title for the session-expiration window."
|
||||
},
|
||||
"window.title.approval": {
|
||||
"message": "Connection Request",
|
||||
"description": "OS window-chrome title for the inbound-connection approval window."
|
||||
},
|
||||
"approval.title.vnc": {
|
||||
"message": "Allow VNC Connection?",
|
||||
"description": "Approval dialog heading for an inbound VNC connection."
|
||||
},
|
||||
"approval.title.ssh": {
|
||||
"message": "Allow SSH Connection?",
|
||||
"description": "Approval dialog heading for an inbound SSH connection."
|
||||
},
|
||||
"approval.title.default": {
|
||||
"message": "Allow Incoming Connection?",
|
||||
"description": "Approval dialog heading for an inbound connection of unknown kind."
|
||||
},
|
||||
"approval.field.user": {
|
||||
"message": "From user",
|
||||
"description": "Approval dialog row label: the initiating user's display name."
|
||||
},
|
||||
"approval.field.keyFingerprint": {
|
||||
"message": "Key fingerprint",
|
||||
"description": "Approval dialog row label: the connecting peer's cryptographic key fingerprint."
|
||||
},
|
||||
"approval.field.peer": {
|
||||
"message": "Via peer",
|
||||
"description": "Approval dialog row label: the peer the connection arrives through."
|
||||
},
|
||||
"approval.field.sourceIp": {
|
||||
"message": "Source IP",
|
||||
"description": "Approval dialog row label: the source IP address of the connection."
|
||||
},
|
||||
"approval.field.osUser": {
|
||||
"message": "OS user",
|
||||
"description": "Approval dialog row label: the target operating-system user."
|
||||
},
|
||||
"approval.countdown": {
|
||||
"message": "Auto-deny in {seconds}s",
|
||||
"description": "Approval dialog countdown; {seconds} is the remaining whole seconds before the daemon auto-denies."
|
||||
},
|
||||
"approval.action.allow": {
|
||||
"message": "Allow",
|
||||
"description": "Approval dialog button: allow the connection."
|
||||
},
|
||||
"approval.action.allowViewOnly": {
|
||||
"message": "Allow (view only)",
|
||||
"description": "Approval dialog button: allow the connection in view-only mode."
|
||||
},
|
||||
"approval.action.deny": {
|
||||
"message": "Deny",
|
||||
"description": "Approval dialog button: deny the connection."
|
||||
},
|
||||
"window.title.updating": {
|
||||
"message": "Updating",
|
||||
"description": "OS window-chrome title for the update / install window."
|
||||
@@ -1775,16 +1855,28 @@
|
||||
"message": "Operation failed.",
|
||||
"description": "Generic fallback error message used when no specific error applies."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"connect.activeSession.badge": {
|
||||
"message": "Screen shared",
|
||||
"description": "Short label on the badge shown on the main screen while somebody is attached to this machine over VNC."
|
||||
},
|
||||
"connect.activeSession.tooltip": {
|
||||
"message": "This screen is being viewed over VNC ({sessionCount} session(s)). Disconnecting ends it, and if you are connected through VNC you will lose access.",
|
||||
"description": "Tooltip on the screen-shared badge. {sessionCount} is how many VNC sessions are attached; wording covers any number since the bundle has no plural forms."
|
||||
},
|
||||
"connect.activeSession.tooltipNamed": {
|
||||
"message": "This screen is being viewed over VNC by {who} ({sessionCount} session(s)). Disconnecting ends it, and if you are connected through VNC you will lose access.",
|
||||
"description": "As connect.activeSession.tooltip, with {who} naming the dashboard users who started the sessions."
|
||||
},
|
||||
"settings.privilege.hint": {
|
||||
"message": "Requires {actor}. Run this instead:",
|
||||
"description": "Help text under an SSH setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
"description": "Help text under a remote-access setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"settings.privilege.oneWay": {
|
||||
"message": "You can switch this off, but switching it back on needs {actor}:",
|
||||
"description": "Warning under an SSH setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
"description": "Warning under a remote-access setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"settings.privilege.oneWayInverted": {
|
||||
"message": "You can switch this on, but switching it back off needs {actor}:",
|
||||
"description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
"description": "Warning under a safeguard setting (SSH authentication, VNC approval) which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,5 +1330,65 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "La operación falló."
|
||||
},
|
||||
"settings.tabs.vnc": {
|
||||
"message": "VNC"
|
||||
},
|
||||
"settings.vnc.section.server": {
|
||||
"message": "Servidor"
|
||||
},
|
||||
"settings.vnc.section.approval": {
|
||||
"message": "Aprobación"
|
||||
},
|
||||
"settings.vnc.server.label": {
|
||||
"message": "Habilitar el servidor VNC"
|
||||
},
|
||||
"settings.vnc.server.help": {
|
||||
"message": "Ejecuta el servidor VNC de NetBird en este host para que los peers autorizados puedan ver o controlar su pantalla."
|
||||
},
|
||||
"settings.vnc.approval.label": {
|
||||
"message": "Requerir aprobación de conexión"
|
||||
},
|
||||
"settings.vnc.approval.help": {
|
||||
"message": "Mostrar en este host una solicitud que debe aceptarse antes de permitir una conexión VNC entrante."
|
||||
},
|
||||
"window.title.approval": {
|
||||
"message": "Solicitud de conexión"
|
||||
},
|
||||
"approval.title.vnc": {
|
||||
"message": "¿Permitir la conexión VNC?"
|
||||
},
|
||||
"approval.title.ssh": {
|
||||
"message": "¿Permitir la conexión SSH?"
|
||||
},
|
||||
"approval.title.default": {
|
||||
"message": "¿Permitir la conexión entrante?"
|
||||
},
|
||||
"approval.field.user": {
|
||||
"message": "Del usuario"
|
||||
},
|
||||
"approval.field.keyFingerprint": {
|
||||
"message": "Huella de la clave"
|
||||
},
|
||||
"approval.field.peer": {
|
||||
"message": "A través del peer"
|
||||
},
|
||||
"approval.field.sourceIp": {
|
||||
"message": "IP de origen"
|
||||
},
|
||||
"approval.field.osUser": {
|
||||
"message": "Usuario del SO"
|
||||
},
|
||||
"approval.countdown": {
|
||||
"message": "Rechazo automático en {seconds}s"
|
||||
},
|
||||
"approval.action.allow": {
|
||||
"message": "Permitir"
|
||||
},
|
||||
"approval.action.allowViewOnly": {
|
||||
"message": "Permitir (solo ver)"
|
||||
},
|
||||
"approval.action.deny": {
|
||||
"message": "Denegar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,5 +1330,65 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "L’opération a échoué."
|
||||
},
|
||||
"settings.tabs.vnc": {
|
||||
"message": "VNC"
|
||||
},
|
||||
"settings.vnc.section.server": {
|
||||
"message": "Serveur"
|
||||
},
|
||||
"settings.vnc.section.approval": {
|
||||
"message": "Approbation"
|
||||
},
|
||||
"settings.vnc.server.label": {
|
||||
"message": "Activer le serveur VNC"
|
||||
},
|
||||
"settings.vnc.server.help": {
|
||||
"message": "Exécuter le serveur VNC de NetBird sur cet hôte afin que les pairs autorisés puissent voir ou contrôler son écran."
|
||||
},
|
||||
"settings.vnc.approval.label": {
|
||||
"message": "Exiger l'approbation des connexions"
|
||||
},
|
||||
"settings.vnc.approval.help": {
|
||||
"message": "Afficher sur cet hôte une invite qui doit être acceptée avant d'autoriser une connexion VNC entrante."
|
||||
},
|
||||
"window.title.approval": {
|
||||
"message": "Demande de connexion"
|
||||
},
|
||||
"approval.title.vnc": {
|
||||
"message": "Autoriser la connexion VNC ?"
|
||||
},
|
||||
"approval.title.ssh": {
|
||||
"message": "Autoriser la connexion SSH ?"
|
||||
},
|
||||
"approval.title.default": {
|
||||
"message": "Autoriser la connexion entrante ?"
|
||||
},
|
||||
"approval.field.user": {
|
||||
"message": "De l'utilisateur"
|
||||
},
|
||||
"approval.field.keyFingerprint": {
|
||||
"message": "Empreinte de clé"
|
||||
},
|
||||
"approval.field.peer": {
|
||||
"message": "Via le pair"
|
||||
},
|
||||
"approval.field.sourceIp": {
|
||||
"message": "IP source"
|
||||
},
|
||||
"approval.field.osUser": {
|
||||
"message": "Utilisateur du système"
|
||||
},
|
||||
"approval.countdown": {
|
||||
"message": "Refus automatique dans {seconds}s"
|
||||
},
|
||||
"approval.action.allow": {
|
||||
"message": "Autoriser"
|
||||
},
|
||||
"approval.action.allowViewOnly": {
|
||||
"message": "Autoriser (lecture seule)"
|
||||
},
|
||||
"approval.action.deny": {
|
||||
"message": "Refuser"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,5 +1330,65 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "A művelet meghiúsult."
|
||||
},
|
||||
"settings.tabs.vnc": {
|
||||
"message": "VNC"
|
||||
},
|
||||
"settings.vnc.section.server": {
|
||||
"message": "Szerver"
|
||||
},
|
||||
"settings.vnc.section.approval": {
|
||||
"message": "Jóváhagyás"
|
||||
},
|
||||
"settings.vnc.server.label": {
|
||||
"message": "VNC szerver engedélyezése"
|
||||
},
|
||||
"settings.vnc.server.help": {
|
||||
"message": "A NetBird VNC szerver futtatása ezen a gépen, hogy az arra jogosult partnerek megtekinthessék vagy vezérelhessék a képernyőjét."
|
||||
},
|
||||
"settings.vnc.approval.label": {
|
||||
"message": "Kapcsolat jóváhagyásának megkövetelése"
|
||||
},
|
||||
"settings.vnc.approval.help": {
|
||||
"message": "Megerősítést kérő ablak megjelenítése ezen a gépen, amelyet el kell fogadni a bejövő VNC-kapcsolat engedélyezése előtt."
|
||||
},
|
||||
"window.title.approval": {
|
||||
"message": "Kapcsolódási kérés"
|
||||
},
|
||||
"approval.title.vnc": {
|
||||
"message": "Engedélyezi a VNC-kapcsolatot?"
|
||||
},
|
||||
"approval.title.ssh": {
|
||||
"message": "Engedélyezi az SSH-kapcsolatot?"
|
||||
},
|
||||
"approval.title.default": {
|
||||
"message": "Engedélyezi a bejövő kapcsolatot?"
|
||||
},
|
||||
"approval.field.user": {
|
||||
"message": "Felhasználótól"
|
||||
},
|
||||
"approval.field.keyFingerprint": {
|
||||
"message": "Kulcs ujjlenyomata"
|
||||
},
|
||||
"approval.field.peer": {
|
||||
"message": "Partneren keresztül"
|
||||
},
|
||||
"approval.field.sourceIp": {
|
||||
"message": "Forrás IP"
|
||||
},
|
||||
"approval.field.osUser": {
|
||||
"message": "OS-felhasználó"
|
||||
},
|
||||
"approval.countdown": {
|
||||
"message": "Automatikus elutasítás {seconds} mp múlva"
|
||||
},
|
||||
"approval.action.allow": {
|
||||
"message": "Engedélyezés"
|
||||
},
|
||||
"approval.action.allowViewOnly": {
|
||||
"message": "Engedélyezés (csak megtekintés)"
|
||||
},
|
||||
"approval.action.deny": {
|
||||
"message": "Elutasítás"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,5 +1330,65 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Operazione non riuscita."
|
||||
},
|
||||
"settings.tabs.vnc": {
|
||||
"message": "VNC"
|
||||
},
|
||||
"settings.vnc.section.server": {
|
||||
"message": "Server"
|
||||
},
|
||||
"settings.vnc.section.approval": {
|
||||
"message": "Approvazione"
|
||||
},
|
||||
"settings.vnc.server.label": {
|
||||
"message": "Abilita server VNC"
|
||||
},
|
||||
"settings.vnc.server.help": {
|
||||
"message": "Esegui il server VNC di NetBird su questo host in modo che i peer autorizzati possano visualizzarne o controllarne lo schermo."
|
||||
},
|
||||
"settings.vnc.approval.label": {
|
||||
"message": "Richiedi l'approvazione della connessione"
|
||||
},
|
||||
"settings.vnc.approval.help": {
|
||||
"message": "Mostra su questo host una richiesta che deve essere accettata prima di consentire una connessione VNC in entrata."
|
||||
},
|
||||
"window.title.approval": {
|
||||
"message": "Richiesta di connessione"
|
||||
},
|
||||
"approval.title.vnc": {
|
||||
"message": "Consentire la connessione VNC?"
|
||||
},
|
||||
"approval.title.ssh": {
|
||||
"message": "Consentire la connessione SSH?"
|
||||
},
|
||||
"approval.title.default": {
|
||||
"message": "Consentire la connessione in entrata?"
|
||||
},
|
||||
"approval.field.user": {
|
||||
"message": "Dall'utente"
|
||||
},
|
||||
"approval.field.keyFingerprint": {
|
||||
"message": "Impronta della chiave"
|
||||
},
|
||||
"approval.field.peer": {
|
||||
"message": "Tramite peer"
|
||||
},
|
||||
"approval.field.sourceIp": {
|
||||
"message": "IP di origine"
|
||||
},
|
||||
"approval.field.osUser": {
|
||||
"message": "Utente del sistema"
|
||||
},
|
||||
"approval.countdown": {
|
||||
"message": "Rifiuto automatico tra {seconds}s"
|
||||
},
|
||||
"approval.action.allow": {
|
||||
"message": "Consenti"
|
||||
},
|
||||
"approval.action.allowViewOnly": {
|
||||
"message": "Consenti (sola visualizzazione)"
|
||||
},
|
||||
"approval.action.deny": {
|
||||
"message": "Rifiuta"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,5 +1330,65 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "A operação falhou."
|
||||
},
|
||||
"settings.tabs.vnc": {
|
||||
"message": "VNC"
|
||||
},
|
||||
"settings.vnc.section.server": {
|
||||
"message": "Servidor"
|
||||
},
|
||||
"settings.vnc.section.approval": {
|
||||
"message": "Aprovação"
|
||||
},
|
||||
"settings.vnc.server.label": {
|
||||
"message": "Ativar servidor VNC"
|
||||
},
|
||||
"settings.vnc.server.help": {
|
||||
"message": "Execute o servidor VNC do NetBird neste host para que os peers autorizados possam ver ou controlar a sua tela."
|
||||
},
|
||||
"settings.vnc.approval.label": {
|
||||
"message": "Exigir aprovação de conexão"
|
||||
},
|
||||
"settings.vnc.approval.help": {
|
||||
"message": "Mostrar neste host um aviso que precisa ser aceito antes de permitir uma conexão VNC de entrada."
|
||||
},
|
||||
"window.title.approval": {
|
||||
"message": "Solicitação de conexão"
|
||||
},
|
||||
"approval.title.vnc": {
|
||||
"message": "Permitir a conexão VNC?"
|
||||
},
|
||||
"approval.title.ssh": {
|
||||
"message": "Permitir a conexão SSH?"
|
||||
},
|
||||
"approval.title.default": {
|
||||
"message": "Permitir a conexão de entrada?"
|
||||
},
|
||||
"approval.field.user": {
|
||||
"message": "Do usuário"
|
||||
},
|
||||
"approval.field.keyFingerprint": {
|
||||
"message": "Impressão digital da chave"
|
||||
},
|
||||
"approval.field.peer": {
|
||||
"message": "Via peer"
|
||||
},
|
||||
"approval.field.sourceIp": {
|
||||
"message": "IP de origem"
|
||||
},
|
||||
"approval.field.osUser": {
|
||||
"message": "Usuário do SO"
|
||||
},
|
||||
"approval.countdown": {
|
||||
"message": "Negação automática em {seconds}s"
|
||||
},
|
||||
"approval.action.allow": {
|
||||
"message": "Permitir"
|
||||
},
|
||||
"approval.action.allowViewOnly": {
|
||||
"message": "Permitir (somente visualização)"
|
||||
},
|
||||
"approval.action.deny": {
|
||||
"message": "Negar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,5 +1330,65 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Не удалось выполнить операцию."
|
||||
},
|
||||
"settings.tabs.vnc": {
|
||||
"message": "VNC"
|
||||
},
|
||||
"settings.vnc.section.server": {
|
||||
"message": "Сервер"
|
||||
},
|
||||
"settings.vnc.section.approval": {
|
||||
"message": "Подтверждение"
|
||||
},
|
||||
"settings.vnc.server.label": {
|
||||
"message": "Включить VNC-сервер"
|
||||
},
|
||||
"settings.vnc.server.help": {
|
||||
"message": "Запустить VNC-сервер NetBird на этом хосте, чтобы авторизованные пиры могли просматривать его экран или управлять им."
|
||||
},
|
||||
"settings.vnc.approval.label": {
|
||||
"message": "Требовать подтверждение подключения"
|
||||
},
|
||||
"settings.vnc.approval.help": {
|
||||
"message": "Показывать на этом хосте запрос, который нужно принять перед разрешением входящего VNC-подключения."
|
||||
},
|
||||
"window.title.approval": {
|
||||
"message": "Запрос на подключение"
|
||||
},
|
||||
"approval.title.vnc": {
|
||||
"message": "Разрешить VNC-подключение?"
|
||||
},
|
||||
"approval.title.ssh": {
|
||||
"message": "Разрешить SSH-подключение?"
|
||||
},
|
||||
"approval.title.default": {
|
||||
"message": "Разрешить входящее подключение?"
|
||||
},
|
||||
"approval.field.user": {
|
||||
"message": "От пользователя"
|
||||
},
|
||||
"approval.field.keyFingerprint": {
|
||||
"message": "Отпечаток ключа"
|
||||
},
|
||||
"approval.field.peer": {
|
||||
"message": "Через пир"
|
||||
},
|
||||
"approval.field.sourceIp": {
|
||||
"message": "IP-адрес источника"
|
||||
},
|
||||
"approval.field.osUser": {
|
||||
"message": "Пользователь ОС"
|
||||
},
|
||||
"approval.countdown": {
|
||||
"message": "Автоотклонение через {seconds} с"
|
||||
},
|
||||
"approval.action.allow": {
|
||||
"message": "Разрешить"
|
||||
},
|
||||
"approval.action.allowViewOnly": {
|
||||
"message": "Разрешить (только просмотр)"
|
||||
},
|
||||
"approval.action.deny": {
|
||||
"message": "Отклонить"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1330,5 +1330,65 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作失败。"
|
||||
},
|
||||
"settings.tabs.vnc": {
|
||||
"message": "VNC"
|
||||
},
|
||||
"settings.vnc.section.server": {
|
||||
"message": "服务器"
|
||||
},
|
||||
"settings.vnc.section.approval": {
|
||||
"message": "批准"
|
||||
},
|
||||
"settings.vnc.server.label": {
|
||||
"message": "启用 VNC 服务器"
|
||||
},
|
||||
"settings.vnc.server.help": {
|
||||
"message": "在此主机上运行 NetBird VNC 服务器,以便授权的对端可以查看或控制其屏幕。"
|
||||
},
|
||||
"settings.vnc.approval.label": {
|
||||
"message": "要求连接批准"
|
||||
},
|
||||
"settings.vnc.approval.help": {
|
||||
"message": "在此主机上显示一个提示,必须先接受该提示才能允许传入的 VNC 连接。"
|
||||
},
|
||||
"window.title.approval": {
|
||||
"message": "连接请求"
|
||||
},
|
||||
"approval.title.vnc": {
|
||||
"message": "允许 VNC 连接?"
|
||||
},
|
||||
"approval.title.ssh": {
|
||||
"message": "允许 SSH 连接?"
|
||||
},
|
||||
"approval.title.default": {
|
||||
"message": "允许传入连接?"
|
||||
},
|
||||
"approval.field.user": {
|
||||
"message": "来自用户"
|
||||
},
|
||||
"approval.field.keyFingerprint": {
|
||||
"message": "密钥指纹"
|
||||
},
|
||||
"approval.field.peer": {
|
||||
"message": "经由对端"
|
||||
},
|
||||
"approval.field.sourceIp": {
|
||||
"message": "源 IP"
|
||||
},
|
||||
"approval.field.osUser": {
|
||||
"message": "操作系统用户"
|
||||
},
|
||||
"approval.countdown": {
|
||||
"message": "{seconds} 秒后自动拒绝"
|
||||
},
|
||||
"approval.action.allow": {
|
||||
"message": "允许"
|
||||
},
|
||||
"approval.action.allowViewOnly": {
|
||||
"message": "允许(仅查看)"
|
||||
},
|
||||
"approval.action.deny": {
|
||||
"message": "拒绝"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +324,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
|
||||
app.RegisterService(application.NewService(s.settings))
|
||||
app.RegisterService(application.NewService(s.networks))
|
||||
app.RegisterService(application.NewService(services.NewForwarding(conn)))
|
||||
app.RegisterService(application.NewService(services.NewApproval(conn)))
|
||||
app.RegisterService(application.NewService(s.profiles))
|
||||
app.RegisterService(application.NewService(services.NewDebug(conn)))
|
||||
app.RegisterService(application.NewService(s.update))
|
||||
|
||||
36
client/ui/services/approval.go
Normal file
36
client/ui/services/approval.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// Approval forwards the user's decision on a pending inbound-connection
|
||||
// approval prompt to the daemon. The daemon pushes the prompt as a SystemEvent
|
||||
// with category APPROVAL; the dialog calls Respond with the same request id to
|
||||
// unblock whichever subsystem (VNC, SSH, ...) is waiting.
|
||||
type Approval struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
func NewApproval(conn DaemonConn) *Approval {
|
||||
return &Approval{conn: conn}
|
||||
}
|
||||
|
||||
// Respond delivers the accept/deny decision for requestID. viewOnly is only
|
||||
// meaningful when accept is true and the subsystem supports a read-only grant.
|
||||
func (a *Approval) Respond(ctx context.Context, requestID string, accept, viewOnly bool) error {
|
||||
cli, err := a.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.RespondApproval(ctx, &proto.RespondApprovalRequest{
|
||||
RequestId: requestID,
|
||||
Accept: accept,
|
||||
ViewOnly: viewOnly,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -106,6 +106,18 @@ type LocalPeer struct {
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
// VNCSession describes one VNC connection currently attached to this machine.
|
||||
// Surfaced so the UI can warn before an action ends a session that may be the
|
||||
// one the person is using: input injected by the VNC agent is indistinguishable
|
||||
// from local input, so the UI cannot tell whether it is being driven remotely.
|
||||
type VNCSession struct {
|
||||
RemoteAddress string `json:"remoteAddress"`
|
||||
Mode string `json:"mode"`
|
||||
// Initiator is the display name of the dashboard user who started the
|
||||
// session, when known.
|
||||
Initiator string `json:"initiator"`
|
||||
}
|
||||
|
||||
// Status is the snapshot the frontend renders on the dashboard.
|
||||
type Status struct {
|
||||
Status string `json:"status"`
|
||||
@@ -122,6 +134,8 @@ type Status struct {
|
||||
// SessionExpiresAt is the absolute UTC instant the SSO session expires; nil
|
||||
// when the peer is not SSO-tracked or login expiration is disabled.
|
||||
SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
|
||||
// VNCSessions lists the VNC connections currently attached to this machine.
|
||||
VNCSessions []VNCSession `json:"vncSessions,omitempty"`
|
||||
}
|
||||
|
||||
// DaemonFeed fans the daemon's two long-running gRPC streams (SubscribeStatus,
|
||||
@@ -524,6 +538,14 @@ func statusFromProto(resp *proto.StatusResponse) Status {
|
||||
},
|
||||
}
|
||||
|
||||
for _, v := range full.GetVncServerState().GetSessions() {
|
||||
st.VNCSessions = append(st.VNCSessions, VNCSession{
|
||||
RemoteAddress: v.GetRemoteAddress(),
|
||||
Mode: v.GetMode(),
|
||||
Initiator: v.GetInitiator(),
|
||||
})
|
||||
}
|
||||
|
||||
for _, p := range full.GetPeers() {
|
||||
st.Peers = append(st.Peers, PeerStatus{
|
||||
IP: p.GetIP(),
|
||||
|
||||
@@ -23,6 +23,8 @@ type MDMFields struct {
|
||||
DisableClientRoutes bool `json:"disableClientRoutes"`
|
||||
DisableServerRoutes bool `json:"disableServerRoutes"`
|
||||
AllowServerSSH *bool `json:"allowServerSSH"`
|
||||
AllowServerVNC *bool `json:"allowServerVNC"`
|
||||
DisableVNCApproval bool `json:"disableVNCApproval"`
|
||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||
DisableAutostart bool `json:"disableAutostart"`
|
||||
BlockInbound bool `json:"blockInbound"`
|
||||
@@ -51,9 +53,11 @@ type Privilege struct {
|
||||
// Actor names what the operation requires ("root", "administrator privileges").
|
||||
Actor string `json:"actor"`
|
||||
// Commands equivalent to the settings the daemon guards, ready to copy.
|
||||
AllowSSHServer string `json:"allowSshServer"`
|
||||
EnableSSHRoot string `json:"enableSshRoot"`
|
||||
DisableSSHAuth string `json:"disableSshAuth"`
|
||||
AllowSSHServer string `json:"allowSshServer"`
|
||||
EnableSSHRoot string `json:"enableSshRoot"`
|
||||
DisableSSHAuth string `json:"disableSshAuth"`
|
||||
AllowVNCServer string `json:"allowVncServer"`
|
||||
DisableVNCApproval string `json:"disableVncApproval"`
|
||||
}
|
||||
|
||||
type ConfigParams struct {
|
||||
@@ -72,6 +76,8 @@ type Config struct {
|
||||
MTU int64 `json:"mtu"`
|
||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||
ServerSSHAllowed bool `json:"serverSshAllowed"`
|
||||
ServerVNCAllowed bool `json:"serverVncAllowed"`
|
||||
DisableVNCApproval bool `json:"disableVncApproval"`
|
||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
||||
DisableNotifications bool `json:"disableNotifications"`
|
||||
@@ -103,6 +109,8 @@ type SetConfigParams struct {
|
||||
PreSharedKey *string `json:"preSharedKey,omitempty"`
|
||||
DisableAutoConnect *bool `json:"disableAutoConnect,omitempty"`
|
||||
ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"`
|
||||
ServerVNCAllowed *bool `json:"serverVncAllowed,omitempty"`
|
||||
DisableVNCApproval *bool `json:"disableVncApproval,omitempty"`
|
||||
RosenpassEnabled *bool `json:"rosenpassEnabled,omitempty"`
|
||||
RosenpassPermissive *bool `json:"rosenpassPermissive,omitempty"`
|
||||
DisableNotifications *bool `json:"disableNotifications,omitempty"`
|
||||
@@ -161,6 +169,8 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
||||
MTU: resp.GetMtu(),
|
||||
DisableAutoConnect: resp.GetDisableAutoConnect(),
|
||||
ServerSSHAllowed: resp.GetServerSSHAllowed(),
|
||||
ServerVNCAllowed: resp.GetServerVNCAllowed(),
|
||||
DisableVNCApproval: resp.GetDisableVNCApproval(),
|
||||
RosenpassEnabled: resp.GetRosenpassEnabled(),
|
||||
RosenpassPermissive: resp.GetRosenpassPermissive(),
|
||||
DisableNotifications: resp.GetDisableNotifications(),
|
||||
@@ -196,6 +206,8 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
OptionalPreSharedKey: p.PreSharedKey,
|
||||
DisableAutoConnect: p.DisableAutoConnect,
|
||||
ServerSSHAllowed: p.ServerSSHAllowed,
|
||||
ServerVNCAllowed: p.ServerVNCAllowed,
|
||||
DisableVNCApproval: p.DisableVNCApproval,
|
||||
RosenpassEnabled: p.RosenpassEnabled,
|
||||
RosenpassPermissive: p.RosenpassPermissive,
|
||||
DisableNotifications: p.DisableNotifications,
|
||||
@@ -223,8 +235,8 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
}
|
||||
|
||||
// Privilege reports whether this UI process could carry out the changes the
|
||||
// daemon restricts to root/administrator, and the command that performs the one
|
||||
// users hit in the SSH settings. It applies the daemon's own rule to what it can
|
||||
// daemon restricts to root/administrator, and the command that performs each of
|
||||
// the ones users hit in the SSH and VNC settings. It applies the daemon's own rule to what it can
|
||||
// see locally, so the frontend can present those controls as unavailable up front
|
||||
// instead of letting a save fail. No daemon round-trip, so it also works while the
|
||||
// daemon is down.
|
||||
@@ -249,11 +261,13 @@ func (s *Settings) Privilege() Privilege {
|
||||
|
||||
func newPrivilege(privileged bool) Privilege {
|
||||
return Privilege{
|
||||
Privileged: privileged,
|
||||
Actor: ipcauth.PrivilegedActor(),
|
||||
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
||||
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
||||
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
|
||||
Privileged: privileged,
|
||||
Actor: ipcauth.PrivilegedActor(),
|
||||
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
||||
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
||||
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
|
||||
AllowVNCServer: ipcauth.UpCommand("--allow-server-vnc"),
|
||||
DisableVNCApproval: ipcauth.UpCommand("--disable-vnc-approval"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,4 +332,8 @@ func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
|
||||
allowed := cfgResp.GetServerSSHAllowed()
|
||||
mdm.AllowServerSSH = &allowed
|
||||
}
|
||||
if _, ok := set["allowServerVNC"]; ok {
|
||||
allowed := cfgResp.GetServerVNCAllowed()
|
||||
mdm.AllowServerVNC = &allowed
|
||||
}
|
||||
}
|
||||
|
||||
44
client/ui/services/settings_mdm_test.go
Normal file
44
client/ui/services/settings_mdm_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func TestApplyMDMRestrictions_VNCFields(t *testing.T) {
|
||||
t.Run("unmanaged leaves fields at zero", func(t *testing.T) {
|
||||
var mdm MDMFields
|
||||
applyMDMRestrictions(&mdm, &proto.GetConfigResponse{})
|
||||
assert.Nil(t, mdm.AllowServerVNC)
|
||||
assert.False(t, mdm.DisableVNCApproval)
|
||||
})
|
||||
|
||||
t.Run("managed surfaces enforced values", func(t *testing.T) {
|
||||
var mdm MDMFields
|
||||
applyMDMRestrictions(&mdm, &proto.GetConfigResponse{
|
||||
MDMManagedFields: []string{"allowServerVNC", "disableVNCApproval"},
|
||||
ServerVNCAllowed: true,
|
||||
DisableVNCApproval: true,
|
||||
})
|
||||
require.NotNil(t, mdm.AllowServerVNC)
|
||||
assert.True(t, *mdm.AllowServerVNC, "AllowServerVNC should carry the enforced value")
|
||||
assert.True(t, mdm.DisableVNCApproval, "DisableVNCApproval should be flagged managed")
|
||||
})
|
||||
|
||||
t.Run("managed VNC disallowed surfaces false", func(t *testing.T) {
|
||||
var mdm MDMFields
|
||||
applyMDMRestrictions(&mdm, &proto.GetConfigResponse{
|
||||
MDMManagedFields: []string{"allowServerVNC"},
|
||||
ServerVNCAllowed: false,
|
||||
})
|
||||
require.NotNil(t, mdm.AllowServerVNC)
|
||||
assert.False(t, *mdm.AllowServerVNC)
|
||||
assert.False(t, mdm.DisableVNCApproval, "unmanaged approval stays zero")
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user