mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-01 05:11:29 +02:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0780a806f2 | ||
|
|
feecb993f4 | ||
|
|
f51fadf8d4 | ||
|
|
aad2702a14 | ||
|
|
7516aa6473 | ||
|
|
aed60a2432 | ||
|
|
234abd7a08 | ||
|
|
c1f0006012 | ||
|
|
cff49237b6 | ||
|
|
1bf54ddd8f | ||
|
|
0f5d2d91fb | ||
|
|
0b7e6a9f46 | ||
|
|
f2c1070f95 | ||
|
|
df39c2b254 | ||
|
|
dd2bdc0de3 | ||
|
|
44fef45c2f | ||
|
|
3d1f209ea3 | ||
|
|
2ef457be95 | ||
|
|
63c320b6a9 | ||
|
|
4acbe2670a | ||
|
|
0fb4c8c423 | ||
|
|
42e45ff9f9 | ||
|
|
9269b56386 | ||
|
|
b3f9b82442 | ||
|
|
8a43f4f943 | ||
|
|
2f268c8141 | ||
|
|
e3c4128164 | ||
|
|
bab5572a74 | ||
|
|
9b4a5df925 | ||
|
|
1816a020c4 | ||
|
|
aa13928b76 | ||
|
|
d681670a9d | ||
|
|
4f6247b5c3 | ||
|
|
1e5b0a5c89 | ||
|
|
b65ec8b68a | ||
|
|
e13bcdbd44 | ||
|
|
46568f7af8 | ||
|
|
3358138ccc | ||
|
|
6d15d0729a | ||
|
|
0936918d24 | ||
|
|
d4a4418969 | ||
|
|
31ed241a1a | ||
|
|
178e6a8530 | ||
|
|
96963b6751 | ||
|
|
9770814f39 | ||
|
|
b0c1ed31b8 | ||
|
|
8435682ac8 | ||
|
|
ed682fad87 |
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).
|
||||
|
||||
|
||||
12
.github/workflows/agent-network-e2e.yml
vendored
12
.github/workflows/agent-network-e2e.yml
vendored
@@ -5,6 +5,13 @@ on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bedrock_model:
|
||||
description: >-
|
||||
Bedrock inference-profile id to drive the matrix with, exactly as
|
||||
AWS issues it. Leave empty for the Sonnet 4.6 default.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -51,6 +58,9 @@ jobs:
|
||||
# token (and URL, for gateways) is unset, so partial coverage is fine.
|
||||
OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }}
|
||||
ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }}
|
||||
# Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire
|
||||
# shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api.
|
||||
KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }}
|
||||
VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }}
|
||||
VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }}
|
||||
OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }}
|
||||
@@ -59,6 +69,8 @@ jobs:
|
||||
CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }}
|
||||
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }}
|
||||
AWS_REGION: ${{ secrets.E2E_AWS_REGION }}
|
||||
# Bedrock model override: dispatch input wins, then the repo variable, else the test default.
|
||||
AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }}
|
||||
# Vertex (Anthropic-on-Vertex): SA + project required; region defaults
|
||||
# to "global", model to a pinned claude snapshot.
|
||||
GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }}
|
||||
|
||||
2
.github/workflows/frontend-ui.yml
vendored
2
.github/workflows/frontend-ui.yml
vendored
@@ -86,7 +86,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Generate Wails bindings
|
||||
run: pnpm run bindings
|
||||
|
||||
4
.github/workflows/golangci-lint.yml
vendored
4
.github/workflows/golangci-lint.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
display_name: Linux
|
||||
name: ${{ matrix.display_name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
@@ -79,4 +79,4 @@ jobs:
|
||||
skip-cache: true
|
||||
skip-save-cache: true
|
||||
cache-invalidation-interval: 0
|
||||
args: --timeout=12m
|
||||
args: --timeout=20m
|
||||
|
||||
87
.github/workflows/test-infrastructure-files.yml
vendored
87
.github/workflows/test-infrastructure-files.yml
vendored
@@ -249,78 +249,35 @@ jobs:
|
||||
docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb
|
||||
docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db
|
||||
|
||||
test-getting-started-script:
|
||||
test-legacy-getting-started-scripts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install jq
|
||||
run: sudo apt-get install -y jq
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: run script with Zitadel PostgreSQL
|
||||
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
|
||||
|
||||
- name: test Caddy file gen postgres
|
||||
run: test -f Caddyfile
|
||||
|
||||
- name: test docker-compose file gen postgres
|
||||
run: test -f docker-compose.yml
|
||||
|
||||
- name: test management.json file gen postgres
|
||||
run: test -f management.json
|
||||
|
||||
- name: test turnserver.conf file gen postgres
|
||||
- name: Verify Dex retirement notice
|
||||
run: |
|
||||
set -x
|
||||
test -f turnserver.conf
|
||||
grep external-ip turnserver.conf
|
||||
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
|
||||
echo "Expected the retired Dex installer to fail"
|
||||
exit 1
|
||||
fi
|
||||
test ! -s stdout.txt
|
||||
grep -Fq "Dex support is not deprecated." stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/local" stderr.txt
|
||||
grep -Fq "removed in NetBird v0.80" stderr.txt
|
||||
|
||||
- name: test zitadel.env file gen postgres
|
||||
run: test -f zitadel.env
|
||||
|
||||
- name: test dashboard.env file gen postgres
|
||||
run: test -f dashboard.env
|
||||
|
||||
- name: test relay.env file gen postgres
|
||||
run: test -f relay.env
|
||||
|
||||
- name: test zdb.env file gen postgres
|
||||
run: test -f zdb.env
|
||||
|
||||
- name: Postgres run cleanup
|
||||
- name: Verify Zitadel retirement notice
|
||||
run: |
|
||||
docker compose down --volumes --rmi all
|
||||
rm -rf docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json zdb.env
|
||||
|
||||
- name: run script with Zitadel CockroachDB
|
||||
run: bash -x infrastructure_files/getting-started-with-zitadel.sh
|
||||
env:
|
||||
NETBIRD_DOMAIN: use-ip
|
||||
ZITADEL_DATABASE: cockroach
|
||||
|
||||
- name: test Caddy file gen CockroachDB
|
||||
run: test -f Caddyfile
|
||||
|
||||
- name: test docker-compose file gen CockroachDB
|
||||
run: test -f docker-compose.yml
|
||||
|
||||
- name: test management.json file gen CockroachDB
|
||||
run: test -f management.json
|
||||
|
||||
- name: test turnserver.conf file gen CockroachDB
|
||||
run: |
|
||||
set -x
|
||||
test -f turnserver.conf
|
||||
grep external-ip turnserver.conf
|
||||
|
||||
- name: test zitadel.env file gen CockroachDB
|
||||
run: test -f zitadel.env
|
||||
|
||||
- name: test dashboard.env file gen CockroachDB
|
||||
run: test -f dashboard.env
|
||||
|
||||
- name: test relay.env file gen CockroachDB
|
||||
run: test -f relay.env
|
||||
if bash infrastructure_files/getting-started-with-zitadel.sh >stdout.txt 2>stderr.txt; then
|
||||
echo "Expected the retired Zitadel installer to fail"
|
||||
exit 1
|
||||
fi
|
||||
test ! -s stdout.txt
|
||||
grep -Fq "Zitadel support and existing Zitadel deployments are not deprecated." stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/zitadel" stderr.txt
|
||||
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-guide" stderr.txt
|
||||
grep -Fq "removed in NetBird v0.80" stderr.txt
|
||||
|
||||
@@ -273,8 +273,8 @@ dockers_v2:
|
||||
- netbirdio/netbird
|
||||
- ghcr.io/netbirdio/netbird
|
||||
tags:
|
||||
- "v{{ .Version }}-rootless"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
- "{{ .Version }}-rootless"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}"
|
||||
dockerfile: client/Dockerfile-rootless
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
|
||||
@@ -24,6 +24,8 @@ builds:
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- production
|
||||
|
||||
- id: netbird-ui-windows-amd64
|
||||
dir: client/ui
|
||||
@@ -39,6 +41,8 @@ builds:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
- -H windowsgui
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- production
|
||||
|
||||
- id: netbird-ui-windows-arm64
|
||||
dir: client/ui
|
||||
@@ -55,6 +59,8 @@ builds:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
- -H windowsgui
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- production
|
||||
|
||||
archives:
|
||||
- id: linux-arch
|
||||
|
||||
@@ -29,6 +29,8 @@ builds:
|
||||
ldflags:
|
||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- production
|
||||
|
||||
universal_binaries:
|
||||
- id: netbird-ui-darwin
|
||||
|
||||
301
CONTRIBUTING.md
301
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
|
||||
@@ -234,12 +316,22 @@ cd client/ui
|
||||
task dev
|
||||
```
|
||||
|
||||
Pass daemon flags after `--`:
|
||||
Pass daemon flags after `--`, pointing the UI at the socket the daemon serves:
|
||||
|
||||
```
|
||||
task dev -- --daemon-addr=npipe://netbird
|
||||
task dev -- --daemon-addr=unix:///var/run/netbird.sock # Linux, macOS
|
||||
task dev -- --daemon-addr=npipe://netbird # Windows
|
||||
```
|
||||
|
||||
On Windows the daemon serves a named pipe (`npipe://netbird`). Which path that
|
||||
ends up being depends on what the daemon may create: as a service or elevated it
|
||||
serves `\\.\pipe\ProtectedPrefix\Administrators\netbird`, which no unprivileged
|
||||
process can take from it, and otherwise it falls back to `\\.\pipe\netbird`.
|
||||
Clients try both and check who owns the pipe before using the plain one. Avoid
|
||||
`tcp://127.0.0.1:41731`: loopback TCP carries no caller identity, so the daemon
|
||||
refuses the operations that require an administrator and you will not exercise
|
||||
those paths.
|
||||
|
||||
Production build (frontend assets embedded into the binary, output in `client/ui/bin/`):
|
||||
|
||||
```
|
||||
@@ -324,23 +416,172 @@ The installer `netbird-installer.exe` will be created in root directory.
|
||||
|
||||
### Test suite
|
||||
|
||||
The tests can be started via:
|
||||
The host-safe unit tests run as a normal user and leave host networking
|
||||
untouched:
|
||||
|
||||
```
|
||||
cd netbird
|
||||
go test -exec sudo ./...
|
||||
make test-unit
|
||||
```
|
||||
|
||||
Tests that need root and mutate host networking (firewall, routing, interface
|
||||
management) carry the `privileged` build tag and run inside a
|
||||
`--privileged --cap-add=NET_ADMIN` Docker container:
|
||||
|
||||
```
|
||||
make test-privileged
|
||||
```
|
||||
|
||||
Narrow a privileged run with environment variables:
|
||||
|
||||
```
|
||||
PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
|
||||
```
|
||||
|
||||
Single packages can be run directly, adding `-race` when the change touches
|
||||
shared state:
|
||||
|
||||
```
|
||||
go test -race ./client/internal/dns/...
|
||||
```
|
||||
|
||||
> On Windows use a powershell with administrator privileges
|
||||
|
||||
## Checklist before submitting a PR
|
||||
As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests:
|
||||
|
||||
As a critical network service and open-source project, we must enforce a few
|
||||
things before submitting a pull request. The
|
||||
[pull request template](/.github/pull_request_template.md) mirrors this list —
|
||||
fill it in rather than deleting it.
|
||||
|
||||
### Link the issue
|
||||
|
||||
The PR description must link the agreed issue (or the validated discussion it
|
||||
came from). See [Ticket first, PR second](#ticket-first-pr-second).
|
||||
|
||||
### Run it locally
|
||||
|
||||
**If you can't run it, you can't submit it.** Build the affected components and
|
||||
exercise the change on a real setup — see [Build and start](#build-and-start).
|
||||
"CI will tell me" is not acceptable for a VPN agent that runs as root on other
|
||||
people's machines.
|
||||
|
||||
### Green CI, and answer the bots
|
||||
|
||||
We do not start reviewing while CI is red. Get the pipeline green first — a
|
||||
failing build, lint, or test means the PR is not ready for review.
|
||||
|
||||
Alongside the test workflows, your PR is reviewed by CodeRabbit and scanned by
|
||||
SonarCloud, Snyk, and Codecov. Read what they report and either fix it or reply
|
||||
with why it does not apply; please do not resolve the threads without a
|
||||
response. They are not always right — this codebase has privileged,
|
||||
platform-specific, and concurrency-heavy paths that static analysis reads poorly
|
||||
— so push back when a finding is wrong rather than changing correct code to
|
||||
silence it. Security and dependency findings are the exception: treat those as
|
||||
real until shown otherwise. Do not edit workflows, thresholds, or scanner
|
||||
configuration to make a check pass.
|
||||
|
||||
### One PR, one purpose
|
||||
|
||||
Bug fix, refactor, feature: separate PRs. Mixed PRs are slow to review, hard to
|
||||
revert, and may be closed with a request to split them.
|
||||
|
||||
### Keep it small
|
||||
|
||||
Size is the strongest predictor of how long a PR waits for review. Aim for under
|
||||
roughly 400 changed lines across under 20 files. Past about 1000 lines or 50
|
||||
files, expect to be asked to split the change — and large PRs from outside the
|
||||
core team may be blocked until the scope has been agreed in a ticket. This is
|
||||
not only about reviewer time: NetBird's agent runs as root on other people's
|
||||
machines, and a sprawling diff cannot be reviewed with the care that deserves.
|
||||
|
||||
Measure by hand-written code, excluding generated output, `go.sum`, and
|
||||
fixtures. If a change genuinely cannot be small — a protocol migration, a
|
||||
cross-component rename — agree the split in the issue before you start, and land
|
||||
it as a series of PRs that each build and make sense on their own.
|
||||
|
||||
### Avoid force-pushing during review
|
||||
|
||||
Once a PR is open, push new commits instead of rewriting history. A force-push
|
||||
detaches existing review comments from their lines, throws away the
|
||||
"changes since your last review" diff, and loses the CI history that showed
|
||||
which commit broke what. Since we squash on merge, there is nothing to gain from
|
||||
a tidy branch history.
|
||||
|
||||
Force-pushing is sometimes unavoidable — rebasing to clear a real conflict, or
|
||||
removing a secret or large binary committed by mistake. When that happens, leave
|
||||
a comment on the PR so reviewers know their anchors moved.
|
||||
|
||||
### Quality checks
|
||||
|
||||
Run these from the repository root before pushing:
|
||||
|
||||
```shell
|
||||
go fmt ./...
|
||||
make lint # golangci-lint on files changed against origin/main
|
||||
make lint-all # full-repository lint, matches CI
|
||||
make test-unit # host-safe unit tests
|
||||
```
|
||||
|
||||
`make setup-hooks` wires `make lint` into a pre-push hook so the fast lint runs
|
||||
automatically. If your change touches privileged paths (firewall, routing,
|
||||
interface management), also run `make test-privileged`, which executes the
|
||||
`privileged`-tagged suite inside a Docker container with `NET_ADMIN`.
|
||||
|
||||
### Code standards
|
||||
|
||||
- Keep functions as simple as possible, with a single purpose
|
||||
- Use private functions and constants where possible
|
||||
- Comment on any new public functions
|
||||
- Add unit tests for any new public function
|
||||
- Comment the **why**, not the **what** — explain non-obvious decisions, invariants, and constraints, not the line below
|
||||
- Keep comments within 90 characters per line and roughly 250 characters per comment; when a block needs more explanation than that, extract a named function instead of writing a longer comment (see [AGENTS.md](AGENTS.md#length-budget))
|
||||
|
||||
### PR title and commits
|
||||
|
||||
PR titles must start with a bracketed tag, enforced by
|
||||
[pr-title-check.yml](/.github/workflows/pr-title-check.yml):
|
||||
|
||||
```text
|
||||
[client] Authorize daemon IPC callers by their local identity
|
||||
[management,client] Add MDM policy support
|
||||
```
|
||||
|
||||
Use a comma-separated list inside a single pair of brackets when a change spans
|
||||
components. The `allowedTags` array in
|
||||
[pr-title-check.yml](/.github/workflows/pr-title-check.yml) is the source of
|
||||
truth — at the time of writing it accepts `management`, `client`, `signal`,
|
||||
`proxy`, `relay`, `misc`, `infrastructure`, `self-hosted`, and `doc`.
|
||||
|
||||
Commit subjects follow the same convention — keep them short and put the
|
||||
reasoning in the body, why before what, with no bullet list of files changed.
|
||||
|
||||
Keep the PR description itself under 1000 words on top of the template text.
|
||||
Reviewers read the diff; the description explains what the diff cannot.
|
||||
|
||||
> When pushing fixes to the PR comments, please push as separate commits; we will squash the PR before merging, so there is no need to squash it before pushing it, and we are more than okay with 10-100 commits in a single PR. This helps review the fixes to the requested changes.
|
||||
|
||||
### Documentation
|
||||
|
||||
User-facing changes need a matching PR in
|
||||
[netbirdio/docs](https://github.com/netbirdio/docs); link it in the PR
|
||||
description, or state why documentation is not needed.
|
||||
|
||||
## When we close a PR
|
||||
|
||||
We would rather redirect early than let a PR sit. We may close one if:
|
||||
|
||||
- It changes behavior with no linked issue, or the approach was never agreed with a maintainer
|
||||
- The change was clearly never run or tested locally
|
||||
- CI has been red without a response
|
||||
- It mixes unrelated purposes, or the purpose is not clear
|
||||
- It is far too large to review and the scope was never agreed in a ticket
|
||||
- The author cannot answer questions about their own change — including PRs that read as unreviewed model output, where review turns into a relay between the maintainer and an LLM. Tooling is fine; unreviewed output is not, you are responsible for the code you sign your name to
|
||||
- There has been no activity for 14 days after we requested changes
|
||||
|
||||
A closed PR is not a rejected idea. Take it back to the
|
||||
[discussion](https://github.com/netbirdio/netbird/discussions), settle the
|
||||
approach, and reopen the work from there.
|
||||
|
||||
## Other project repositories
|
||||
|
||||
NetBird project is composed of 3 main repositories:
|
||||
|
||||
72
SECURITY.md
72
SECURITY.md
@@ -1,12 +1,70 @@
|
||||
# Security Policy
|
||||
|
||||
NetBird's goal is to provide a secure network. If you find a vulnerability or bug, please report it by opening an issue [here](https://github.com/netbirdio/netbird/issues/new?assignees=&labels=&template=bug-issue-report.md&title=) or by contacting us by email.
|
||||
|
||||
There has yet to be an official bug bounty program for the NetBird project.
|
||||
|
||||
## Supported Versions
|
||||
- We currently support only the latest version
|
||||
NetBird's goal is to provide a secure network. The client runs as a privileged service on every machine it is installed on,
|
||||
so we take reports about it seriously and we publish what we fix.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues to `security@netbird.io`
|
||||
**Please do not open a public issue for a security vulnerability.** Public issues are visible to everyone, including before
|
||||
a fix is available.
|
||||
|
||||
Report security issues one of these two ways:
|
||||
|
||||
- **GitHub private vulnerability reporting** — [open a private report](https://github.com/netbirdio/netbird/security/advisories/new)
|
||||
on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place.
|
||||
- **Email** — `security@netbird.io`.
|
||||
|
||||
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open-source code, email us rather than
|
||||
filing a repository report.
|
||||
|
||||
### What to include
|
||||
|
||||
A report is easier to act on when it contains:
|
||||
|
||||
- The affected component (client, management, signal, relay, dashboard) and the version or commit you tested
|
||||
- The platform and configuration, where relevant — operating system, self-hosted or NetBird Cloud, container or host install
|
||||
- What an attacker needs before they can exploit it: network position, an account, local access, a specific privilege level
|
||||
- Steps to reproduce, and a proof of concept if you have one
|
||||
- The impact you believe it has
|
||||
|
||||
Partial reports are still welcome. If you are unsure whether something is a security issue, send it to `security@netbird.io`
|
||||
and let us make that call.
|
||||
|
||||
## What to expect from us
|
||||
|
||||
- **We acknowledge your report** and tell you whether we can reproduce it.
|
||||
- **We work with you on severity and scope.** If we assess it differently than you do, we will explain why rather than
|
||||
silently downgrade it.
|
||||
- **We fix and release**, then publish a [GitHub Security Advisory](https://github.com/netbirdio/netbird/security/advisories)
|
||||
naming the affected version range and the patched version.
|
||||
- **We credit reporters who want to be credited.** Tell us the name or handle you would like used, or that you would rather
|
||||
stay anonymous.
|
||||
- **We keep you in the loop** until the advisory is published.
|
||||
|
||||
We ask that you give us a reasonable opportunity to ship a fix before disclosing the issue publicly, and that you avoid
|
||||
accessing, modifying, or exfiltrating data belonging to other people while testing. Testing against your own installation
|
||||
or your own account is always fine.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We support the latest release. Security fixes ship in the next version rather than as backports to older releases, so
|
||||
upgrading to the current release is how you get them.
|
||||
|
||||
Release notifications are available by watching [releases](https://github.com/netbirdio/netbird/releases).
|
||||
|
||||
## Published advisories
|
||||
|
||||
Every vulnerability we fix is published as a GitHub Security Advisory on the
|
||||
[advisories page](https://github.com/netbirdio/netbird/security/advisories), including the affected version range, the
|
||||
patched version, and the reporter's credit. Advisories for the Go module are also distributed through the Go vulnerability
|
||||
database, so `govulncheck` will report them against your dependencies.
|
||||
|
||||
## Bug bounty
|
||||
|
||||
There is no official bug bounty program for the NetBird project. We credit reporters in advisories, and we are grateful for
|
||||
the work, but we cannot currently offer payment for reports.
|
||||
|
||||
## Non-security bugs
|
||||
|
||||
For bugs that are not security issues, please use the
|
||||
[issue tracker](https://github.com/netbirdio/netbird/discussions/new/choose).
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -75,6 +76,24 @@ type Client struct {
|
||||
connectClient *internal.ConnectClient
|
||||
config *profilemanager.Config
|
||||
cacheDir string
|
||||
|
||||
stateChangeMu sync.Mutex
|
||||
stateChangeSubID string
|
||||
eventSub *peer.EventSubscription
|
||||
// Closed to stop the watch goroutines from delivering buffered items to a
|
||||
// listener that has been removed or replaced. See stopStateChangeWatchLocked.
|
||||
stateChangeDone chan struct{}
|
||||
|
||||
// Latched "the server wants an interactive login": survives the engine
|
||||
// restarts that replace the run loop's context state. See Client.Status.
|
||||
// Guarded by loginRequiredMu together with loginCleared, which counts
|
||||
// clears so a stale observation cannot re-latch over one.
|
||||
loginRequiredMu sync.Mutex
|
||||
loginRequired bool
|
||||
loginCleared uint64
|
||||
|
||||
extendMu sync.Mutex
|
||||
extendCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
||||
@@ -148,11 +167,16 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
c.setState(cfg, cacheDir, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
// only once the fresh connect client is installed: until then Status()
|
||||
// still reads the previous run's context state, which holds the NeedsLogin
|
||||
// that prompted this login, and would re-latch what was just cleared.
|
||||
c.clearLoginRequired()
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
|
||||
@@ -277,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)
|
||||
}
|
||||
@@ -299,6 +323,13 @@ func (c *Client) SetInfoLogLevel() {
|
||||
// PeersList return with the list of the PeerInfos
|
||||
func (c *Client) PeersList() *PeerInfoArray {
|
||||
|
||||
// The recorder only caches transfer counters and handshake times; nothing
|
||||
// refreshes them on its own, so without this they read as zero. The desktop
|
||||
// daemon does the same before serving a full peer status.
|
||||
if err := c.recorder.RefreshWireGuardStats(); err != nil {
|
||||
log.Debugf("failed to refresh WireGuard stats: %v", err)
|
||||
}
|
||||
|
||||
fullStatus := c.recorder.GetFullStatus()
|
||||
|
||||
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
|
||||
@@ -309,6 +340,20 @@ func (c *Client) PeersList() *PeerInfoArray {
|
||||
FQDN: p.FQDN,
|
||||
ConnStatus: int(p.ConnStatus),
|
||||
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
|
||||
|
||||
PubKey: p.PubKey,
|
||||
Latency: formatDuration(p.Latency),
|
||||
LatencyMs: p.Latency.Milliseconds(),
|
||||
BytesRx: p.BytesRx,
|
||||
BytesTx: p.BytesTx,
|
||||
ConnStatusUpdate: formatTime(p.ConnStatusUpdate),
|
||||
Relayed: p.Relayed,
|
||||
RosenpassEnabled: p.RosenpassEnabled,
|
||||
LastWireguardHandshake: formatTime(p.LastWireguardHandshake),
|
||||
LocalIceCandidateType: p.LocalIceCandidateType,
|
||||
RemoteIceCandidateType: p.RemoteIceCandidateType,
|
||||
LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint,
|
||||
RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint,
|
||||
}
|
||||
peerInfos[n] = pi
|
||||
}
|
||||
@@ -439,10 +484,6 @@ func (c *Client) RemoveConnectionListener() {
|
||||
c.recorder.RemoveConnectionListener()
|
||||
}
|
||||
|
||||
func (c *Client) toggleRoute(command routeCommand) error {
|
||||
return command.toggleRoute()
|
||||
}
|
||||
|
||||
func (c *Client) getRouteManager() (routemanager.Manager, error) {
|
||||
client := c.getConnectClient()
|
||||
if client == nil {
|
||||
@@ -462,22 +503,22 @@ func (c *Client) getRouteManager() (routemanager.Manager, error) {
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (c *Client) SelectRoute(route string) error {
|
||||
func (c *Client) SelectRoute(id string) error {
|
||||
manager, err := c.getRouteManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.toggleRoute(selectRouteCommand{route: route, manager: manager})
|
||||
return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true)
|
||||
}
|
||||
|
||||
func (c *Client) DeselectRoute(route string) error {
|
||||
func (c *Client) DeselectRoute(id string) error {
|
||||
manager, err := c.getRouteManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.toggleRoute(deselectRouteCommand{route: route, manager: manager})
|
||||
return manager.DeselectRoutes([]route.NetID{route.NetID(id)})
|
||||
}
|
||||
|
||||
// getNetworkDomainsFromRoute extracts domains from a route and enriches each domain
|
||||
@@ -512,3 +553,28 @@ func exportEnvList(list *EnvList) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatDuration renders a duration for display, trimming the fractional part
|
||||
// to two digits so latencies read as "12.34ms" rather than "12.345678ms".
|
||||
func formatDuration(d time.Duration) string {
|
||||
ds := d.String()
|
||||
dotIndex := strings.Index(ds, ".")
|
||||
if dotIndex == -1 {
|
||||
return ds
|
||||
}
|
||||
|
||||
endIndex := min(dotIndex+3, len(ds))
|
||||
|
||||
// Skip the remaining digits so only the unit suffix is appended back.
|
||||
unitStart := endIndex
|
||||
for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' {
|
||||
unitStart++
|
||||
}
|
||||
return ds[:endIndex] + ds[unitStart:]
|
||||
}
|
||||
|
||||
// formatTime renders a timestamp in UTC using a fixed layout. The zero time is
|
||||
// passed through as-is so the UI can recognise it and show "never" instead.
|
||||
func formatTime(t time.Time) string {
|
||||
return t.UTC().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
@@ -12,12 +12,30 @@ const (
|
||||
)
|
||||
|
||||
// PeerInfo describe information about the peers. It designed for the UI usage
|
||||
//
|
||||
// The fields below ConnStatus back the peer detail screen. Durations and times
|
||||
// are pre-formatted into strings so the UI does not have to know Go's layouts;
|
||||
// Latency is additionally exposed as LatencyMs for colour coding.
|
||||
type PeerInfo struct {
|
||||
IP string
|
||||
IPv6 string
|
||||
FQDN string
|
||||
ConnStatus int
|
||||
Routes PeerRoutes
|
||||
|
||||
PubKey string
|
||||
Latency string
|
||||
LatencyMs int64
|
||||
BytesRx int64
|
||||
BytesTx int64
|
||||
ConnStatusUpdate string
|
||||
Relayed bool
|
||||
RosenpassEnabled bool
|
||||
LastWireguardHandshake string
|
||||
LocalIceCandidateType string
|
||||
RemoteIceCandidateType string
|
||||
LocalIceCandidateEndpoint string
|
||||
RemoteIceCandidateEndpoint string
|
||||
}
|
||||
|
||||
func (p *PeerInfo) GetPeerRoutes() *PeerRoutes {
|
||||
|
||||
@@ -145,7 +145,7 @@ func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
// AddProfile creates a new profile
|
||||
func (pm *ProfileManager) AddProfile(profileName string) error {
|
||||
// Use ServiceManager (creates profile in profiles/ directory)
|
||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername, nil)
|
||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add profile: %w", err)
|
||||
}
|
||||
@@ -189,6 +189,19 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameProfile changes a profile's display name. The profile ID, and therefore
|
||||
// its on-disk filename, is left untouched: only the "name" field of the config
|
||||
// is rewritten. This works for the default profile too, whose config lives in
|
||||
// netbird.cfg rather than under profiles/.
|
||||
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil {
|
||||
return fmt.Errorf("failed to rename profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("renamed profile %s to: %s", id, newName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProfile deletes a profile
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
// Use ServiceManager (removes profile from profiles/ directory)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func executeRouteToggle(id string, manager routemanager.Manager,
|
||||
operationName string,
|
||||
routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error {
|
||||
netID := route.NetID(id)
|
||||
routes := []route.NetID{netID}
|
||||
|
||||
routesMap := manager.GetClientRoutesWithNetID()
|
||||
routes = route.ExpandV6ExitPairs(routes, routesMap)
|
||||
|
||||
log.Debugf("%s with ids: %v", operationName, routes)
|
||||
|
||||
if err := routeOperation(routes, maps.Keys(routesMap)); err != nil {
|
||||
log.Debugf("error when %s: %s", operationName, err)
|
||||
return fmt.Errorf("error %s: %w", operationName, err)
|
||||
}
|
||||
|
||||
manager.TriggerSelection(manager.GetClientRoutes())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type routeCommand interface {
|
||||
toggleRoute() error
|
||||
}
|
||||
|
||||
type selectRouteCommand struct {
|
||||
route string
|
||||
manager routemanager.Manager
|
||||
}
|
||||
|
||||
func (s selectRouteCommand) toggleRoute() error {
|
||||
routeSelector := s.manager.GetRouteSelector()
|
||||
if routeSelector == nil {
|
||||
return fmt.Errorf("no route selector available")
|
||||
}
|
||||
|
||||
routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error {
|
||||
return routeSelector.SelectRoutes(routes, true, allRoutes)
|
||||
}
|
||||
|
||||
return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation)
|
||||
}
|
||||
|
||||
type deselectRouteCommand struct {
|
||||
route string
|
||||
manager routemanager.Manager
|
||||
}
|
||||
|
||||
func (d deselectRouteCommand) toggleRoute() error {
|
||||
routeSelector := d.manager.GetRouteSelector()
|
||||
if routeSelector == nil {
|
||||
return fmt.Errorf("no route selector available")
|
||||
}
|
||||
|
||||
return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes)
|
||||
}
|
||||
309
client/android/session.go
Normal file
309
client/android/session.go
Normal file
@@ -0,0 +1,309 @@
|
||||
//go:build android
|
||||
|
||||
package android
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// StateChangeListener receives client state notifications.
|
||||
//
|
||||
// OnStateChanged is a payload-free wake-up whenever the state snapshot
|
||||
// changed: connection state, the run-loop status label (e.g. NeedsLogin) or
|
||||
// the session deadline. It mirrors the daemon's SubscribeStatus stream
|
||||
// trigger — on each signal the consumer pulls the fresh values via
|
||||
// Status() / SessionExpiresAtUnix().
|
||||
//
|
||||
// OnSessionExpiring forwards the engine's session-expiry warnings, fired at
|
||||
// sessionwatch.WarningLead before the deadline and again at FinalWarningLead
|
||||
// (finalWarning true). The second one is suppressed when the user dismissed
|
||||
// the first via DismissSessionWarning. The daemon turns the same events into
|
||||
// its tray notification.
|
||||
type StateChangeListener interface {
|
||||
OnStateChanged()
|
||||
OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool)
|
||||
}
|
||||
|
||||
// Status returns the connect run-loop's status label — the same value the
|
||||
// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the
|
||||
// management server rejected the peer and an interactive login is required.
|
||||
//
|
||||
// The label is latched: the run loop keeps its status in a per-run context
|
||||
// state, which a restart replaces with a fresh Idle one, so an engine restart
|
||||
// (network change, always-on) would otherwise erase the fact that the peer
|
||||
// still needs to log in. Only a successful interactive login or extend clears
|
||||
// it — see clearLoginRequired.
|
||||
func (c *Client) Status() string {
|
||||
latched, generation := c.loginRequiredState()
|
||||
if latched {
|
||||
return string(internal.StatusNeedsLogin)
|
||||
}
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
return string(internal.StatusIdle)
|
||||
}
|
||||
status := cc.Status()
|
||||
if status == internal.StatusNeedsLogin {
|
||||
c.latchLoginRequired(generation)
|
||||
}
|
||||
return string(status)
|
||||
}
|
||||
|
||||
func (c *Client) loginRequiredState() (bool, uint64) {
|
||||
c.loginRequiredMu.Lock()
|
||||
defer c.loginRequiredMu.Unlock()
|
||||
return c.loginRequired, c.loginCleared
|
||||
}
|
||||
|
||||
// latchLoginRequired records a NeedsLogin observation, unless a clear landed
|
||||
// while the caller was reading the run loop's status: cc.Status() is read
|
||||
// outside the lock, so a login or extend completing in that window would
|
||||
// otherwise be undone by this stale observation, stranding the UI on
|
||||
// "login required" over a healthy session.
|
||||
func (c *Client) latchLoginRequired(observedGeneration uint64) {
|
||||
c.loginRequiredMu.Lock()
|
||||
defer c.loginRequiredMu.Unlock()
|
||||
if c.loginCleared != observedGeneration {
|
||||
return
|
||||
}
|
||||
c.loginRequired = true
|
||||
}
|
||||
|
||||
// clearLoginRequired releases the latch after a successful interactive login
|
||||
// or session extend, and invalidates any observation already in flight.
|
||||
func (c *Client) clearLoginRequired() {
|
||||
c.loginRequiredMu.Lock()
|
||||
defer c.loginRequiredMu.Unlock()
|
||||
c.loginRequired = false
|
||||
c.loginCleared++
|
||||
}
|
||||
|
||||
// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0
|
||||
// when no deadline is known (not SSO-registered, expiry disabled, or the
|
||||
// engine has not received one yet). A past value means the session expired.
|
||||
// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon.
|
||||
func (c *Client) SessionExpiresAtUnix() int64 {
|
||||
deadline := c.recorder.GetSessionExpiresAt()
|
||||
if deadline.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return deadline.Unix()
|
||||
}
|
||||
|
||||
// SetStateChangeListener registers the state notification listener.
|
||||
// Replaces any previously registered listener; remove it with
|
||||
// RemoveStateChangeListener.
|
||||
func (c *Client) SetStateChangeListener(listener StateChangeListener) {
|
||||
c.stateChangeMu.Lock()
|
||||
defer c.stateChangeMu.Unlock()
|
||||
c.stopStateChangeWatchLocked()
|
||||
if listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Both subscriptions are buffered (one pending tick, ten pending events),
|
||||
// so unsubscribing is not enough to stop callbacks: the loops would drain
|
||||
// what is already queued and deliver it to a listener the caller has
|
||||
// already removed or replaced. Gate every callback on this registration's
|
||||
// own signal, which is closed before unsubscribing.
|
||||
done := make(chan struct{})
|
||||
c.stateChangeDone = done
|
||||
|
||||
id, ch := c.recorder.SubscribeToStateChanges()
|
||||
c.stateChangeSubID = id
|
||||
// The channel is closed by UnsubscribeFromStateChanges, which ends the
|
||||
// goroutine. Ticks are coalesced (buffer of one), so a burst of changes
|
||||
// wakes the listener once.
|
||||
go func() {
|
||||
for range ch {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
listener.OnStateChanged()
|
||||
}
|
||||
}()
|
||||
|
||||
c.eventSub = c.recorder.SubscribeToEvents()
|
||||
go watchSessionWarnings(c.eventSub, listener, done)
|
||||
}
|
||||
|
||||
// RemoveStateChangeListener unregisters the state notification listener.
|
||||
func (c *Client) RemoveStateChangeListener() {
|
||||
c.stateChangeMu.Lock()
|
||||
defer c.stateChangeMu.Unlock()
|
||||
c.stopStateChangeWatchLocked()
|
||||
}
|
||||
|
||||
// DismissSessionWarning records the user's "Dismiss" on the first expiry
|
||||
// warning and suppresses the final one for the current deadline. A refreshed
|
||||
// deadline re-arms both. No-op while the engine is not running.
|
||||
func (c *Client) DismissSessionWarning() {
|
||||
cc := c.getConnectClient()
|
||||
if cc == nil {
|
||||
return
|
||||
}
|
||||
engine := cc.Engine()
|
||||
if engine == nil {
|
||||
return
|
||||
}
|
||||
engine.DismissSessionWarning()
|
||||
}
|
||||
|
||||
// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and
|
||||
// asks the management server to extend the session deadline. The tunnel is
|
||||
// untouched: no resync, no reconnect. Async; the result arrives on the
|
||||
// listener. Mirror of the daemon's RequestExtendAuthSession /
|
||||
// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the
|
||||
// browser" role.
|
||||
//
|
||||
// Only one flow may be in flight: the PKCE step binds a fixed loopback port,
|
||||
// so a second concurrent flow would fail on that bind. Call
|
||||
// CancelExtendAuthSession when the user abandons the browser.
|
||||
func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) {
|
||||
ctx, err := c.beginExtend()
|
||||
if err != nil {
|
||||
resultListener.OnError(err)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer c.endExtend()
|
||||
if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil {
|
||||
resultListener.OnError(err)
|
||||
return
|
||||
}
|
||||
resultListener.OnSuccess()
|
||||
}()
|
||||
}
|
||||
|
||||
// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is
|
||||
// left alone — unlike the login flow, which cancels the whole client context
|
||||
// by stopping the engine. Without this the abandoned PKCE wait keeps its
|
||||
// loopback port for the full flow timeout and blocks every later attempt.
|
||||
// No-op when no flow is running.
|
||||
func (c *Client) CancelExtendAuthSession() {
|
||||
c.extendMu.Lock()
|
||||
defer c.extendMu.Unlock()
|
||||
if c.extendCancel != nil {
|
||||
c.extendCancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) stopStateChangeWatchLocked() {
|
||||
// Signal first, unsubscribe second: closing the channels only stops new
|
||||
// items, and the loops would still hand whatever is buffered to a listener
|
||||
// that is no longer registered.
|
||||
if c.stateChangeDone != nil {
|
||||
close(c.stateChangeDone)
|
||||
c.stateChangeDone = nil
|
||||
}
|
||||
if c.stateChangeSubID != "" {
|
||||
c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID)
|
||||
c.stateChangeSubID = ""
|
||||
}
|
||||
if c.eventSub != nil {
|
||||
// Closes the channel, which ends watchSessionWarnings.
|
||||
c.recorder.UnsubscribeFromEvents(c.eventSub)
|
||||
c.eventSub = nil
|
||||
}
|
||||
}
|
||||
|
||||
// watchSessionWarnings forwards the engine's session-expiry warnings to the
|
||||
// listener. The event stream also carries unrelated traffic — network-map
|
||||
// updates on every sync, DNS and route errors — so everything but an
|
||||
// AUTHENTICATION event carrying the session-warning marker is dropped. Exits
|
||||
// when the subscription is closed by UnsubscribeFromEvents, or earlier when
|
||||
// done is closed — the stream buffers up to ten events, and a deregistered
|
||||
// listener must not receive the ones already queued.
|
||||
func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) {
|
||||
for ev := range sub.Events() {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION {
|
||||
continue
|
||||
}
|
||||
meta := ev.GetMetadata()
|
||||
if meta[sessionwatch.MetaSessionWarning] != "true" {
|
||||
// Other AUTHENTICATION events exist (e.g. a deadline rejected as
|
||||
// out of range); they carry no warning marker.
|
||||
continue
|
||||
}
|
||||
deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt])
|
||||
if err != nil {
|
||||
log.Warnf("session warning event with unparsable deadline: %v", err)
|
||||
continue
|
||||
}
|
||||
lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes])
|
||||
if err != nil {
|
||||
// Informational only — the deadline above is what drives the UI.
|
||||
lead = 0
|
||||
}
|
||||
listener.OnSessionExpiring(deadline.Unix(), int64(lead),
|
||||
meta[sessionwatch.MetaSessionFinal] == "true")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) beginExtend() (context.Context, error) {
|
||||
c.extendMu.Lock()
|
||||
defer c.extendMu.Unlock()
|
||||
if c.extendCancel != nil {
|
||||
return nil, fmt.Errorf("session extend already in progress")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.extendCancel = cancel
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func (c *Client) endExtend() {
|
||||
c.extendMu.Lock()
|
||||
defer c.extendMu.Unlock()
|
||||
if c.extendCancel != nil {
|
||||
c.extendCancel()
|
||||
c.extendCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
|
||||
cfg, _, cc := c.stateSnapshot()
|
||||
if cfg == nil || cc == nil {
|
||||
return fmt.Errorf("engine is not running")
|
||||
}
|
||||
engine := cc.Engine()
|
||||
if engine == nil {
|
||||
return fmt.Errorf("engine is not initialized")
|
||||
}
|
||||
|
||||
authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth client: %v", err)
|
||||
}
|
||||
defer authClient.Close()
|
||||
|
||||
a := &Auth{ctx: ctx, config: cfg}
|
||||
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interactive sso login failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil {
|
||||
return err
|
||||
}
|
||||
c.clearLoginRequired()
|
||||
|
||||
go urlOpener.OnLoginSuccess()
|
||||
return nil
|
||||
}
|
||||
66
client/cmd/daemon_error.go
Normal file
66
client/cmd/daemon_error.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// daemonCallError prepares a daemon error for display. A refusal the daemon
|
||||
// raised because the operation needs root/administrator is already guidance
|
||||
// written for the user, so it is surfaced on its own instead of buried under the
|
||||
// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped
|
||||
// with context as usual.
|
||||
func daemonCallError(context string, err error) error {
|
||||
if guidance, ok := privilegeGuidance(err); ok {
|
||||
return errors.New(guidance)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", context, err)
|
||||
}
|
||||
|
||||
// privilegeGuidance renders the daemon's privilege refusal as a summary and the
|
||||
// command that performs the operation with the privileges it needs. It reports
|
||||
// false for any other error.
|
||||
func privilegeGuidance(err error) (string, bool) {
|
||||
info, ok := privilegeErrorInfo(err)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
|
||||
command := info.GetMetadata()[ipcauth.ErrorMetaCommand]
|
||||
if summary == "" {
|
||||
// Detail without a summary: fall back to the status message, which
|
||||
// carries the same text.
|
||||
summary = strings.TrimSpace(gstatus.Convert(err).Message())
|
||||
}
|
||||
if command == "" {
|
||||
return summary, true
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s\n\n %s\n", summary, command), true
|
||||
}
|
||||
|
||||
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
|
||||
// carries one.
|
||||
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, detail := range gstatus.Convert(err).Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
|
||||
return info, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// SetSSHConfigCmdName is the hidden subcommand an elevated process runs to apply
|
||||
// the privileged SSH settings.
|
||||
const SetSSHConfigCmdName = "set-ssh-config"
|
||||
|
||||
// sshConfigElevated is set by runInDaemonMode once the dangerous SSH settings
|
||||
// have been applied by an elevated helper, so the (unprivileged) up flow does
|
||||
// not re-send them and re-trip the daemon gate.
|
||||
var sshConfigElevated bool
|
||||
|
||||
// wantsDangerousSSH reports whether this invocation is trying to ENABLE SSH root
|
||||
// login or DISABLE SSH authentication.
|
||||
func wantsDangerousSSH(cmd *cobra.Command) bool {
|
||||
return (cmd.Flags().Changed(enableSSHRootFlag) && enableSSHRoot) ||
|
||||
(cmd.Flags().Changed(disableSSHAuthFlag) && disableSSHAuth)
|
||||
}
|
||||
|
||||
// buildSetSSHConfigArgs builds the argument list for an elevated
|
||||
// `netbird set-ssh-config` invocation.
|
||||
func buildSetSSHConfigArgs(profileName, username string, enableSSHRoot, disableSSHAuth *bool, daemonAddr string) []string {
|
||||
args := []string{SetSSHConfigCmdName}
|
||||
if profileName != "" {
|
||||
args = append(args, "--profile", profileName)
|
||||
}
|
||||
if username != "" {
|
||||
args = append(args, "--username", username)
|
||||
}
|
||||
if enableSSHRoot != nil && *enableSSHRoot {
|
||||
args = append(args, "--"+enableSSHRootFlag)
|
||||
}
|
||||
if disableSSHAuth != nil && *disableSSHAuth {
|
||||
args = append(args, "--"+disableSSHAuthFlag)
|
||||
}
|
||||
if daemonAddr != "" {
|
||||
args = append(args, "--daemon-addr", daemonAddr)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// ElevateSSHConfig re-runs the current executable's `set-ssh-config` command with
|
||||
// root/administrator privileges via the platform's prompt (pkexec/UAC/osascript),
|
||||
// so the elevated process connects to the daemon with a privileged identity and
|
||||
// the daemon's requirePrivilegedForDangerousSSH gate passes.
|
||||
func ElevateSSHConfig(profileName, username string, enableSSHRoot, disableSSHAuth *bool) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("locate executable for elevation: %w", err)
|
||||
}
|
||||
return runElevated(exe, buildSetSSHConfigArgs(profileName, username, enableSSHRoot, disableSSHAuth, daemonAddr))
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// isProcessPrivileged reports whether the current process runs as root.
|
||||
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
|
||||
|
||||
// TODO(ssh-elevation): implement the osascript admin prompt.
|
||||
func runElevated(_ string, _ []string) error {
|
||||
return fmt.Errorf("automatic privilege elevation is not yet implemented on macOS, re-run with sudo")
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isProcessPrivileged reports whether the current process runs as root.
|
||||
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
|
||||
|
||||
// runElevated re-runs exe with args as root. On a graphical session it uses
|
||||
// pkexec, which drives the desktop's polkit authentication agent (GUI prompt).
|
||||
func runElevated(exe string, args []string) error {
|
||||
if !hasGraphicalSession() {
|
||||
return fmt.Errorf("cannot request privilege elevation without a graphical session. re-run as root: sudo %s %s", exe, strings.Join(args, " "))
|
||||
}
|
||||
pkexec, err := exec.LookPath("pkexec")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pkexec not found for privilege elevation, re-run as root: sudo %s %s", exe, strings.Join(args, " "))
|
||||
}
|
||||
|
||||
c := exec.Command(pkexec, append([]string{exe}, args...)...)
|
||||
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return fmt.Errorf("elevated %s failed (elevation cancelled or denied?): %w", SetSSHConfigCmdName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasGraphicalSession heuristically reports whether a desktop session is present
|
||||
// that polkit can prompt in.
|
||||
func hasGraphicalSession() bool {
|
||||
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isProcessPrivileged reports whether the current process runs as root.
|
||||
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
|
||||
|
||||
// runElevated has no automatic elevation mechanism on these platforms
|
||||
func runElevated(exe string, args []string) error {
|
||||
return fmt.Errorf("automatic privilege elevation is not supported on this platform, re-run as root: sudo %s %s", exe, strings.Join(args, " "))
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
//go:build !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildSetSSHConfigArgs(t *testing.T) {
|
||||
tr, fa := true, false
|
||||
|
||||
t.Run("enable root only, with daemon-addr", func(t *testing.T) {
|
||||
got := buildSetSSHConfigArgs("prof", "alice", &tr, nil, "unix:///x.sock")
|
||||
assert.Equal(t, []string{
|
||||
SetSSHConfigCmdName, "--profile", "prof", "--username", "alice",
|
||||
"--" + enableSSHRootFlag, "--daemon-addr", "unix:///x.sock",
|
||||
}, got)
|
||||
})
|
||||
|
||||
t.Run("disable auth only, no daemon-addr", func(t *testing.T) {
|
||||
got := buildSetSSHConfigArgs("prof", "alice", nil, &tr, "")
|
||||
assert.Equal(t, []string{
|
||||
SetSSHConfigCmdName, "--profile", "prof", "--username", "alice",
|
||||
"--" + disableSSHAuthFlag,
|
||||
}, got)
|
||||
})
|
||||
|
||||
t.Run("false pointers omit the flags", func(t *testing.T) {
|
||||
got := buildSetSSHConfigArgs("", "", &fa, &fa, "")
|
||||
assert.Equal(t, []string{SetSSHConfigCmdName}, got)
|
||||
})
|
||||
|
||||
t.Run("both enabled", func(t *testing.T) {
|
||||
got := buildSetSSHConfigArgs("p", "u", &tr, &tr, "")
|
||||
assert.Equal(t, []string{
|
||||
SetSSHConfigCmdName, "--profile", "p", "--username", "u",
|
||||
"--" + enableSSHRootFlag, "--" + disableSSHAuthFlag,
|
||||
}, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildSetSSHConfigRequest(t *testing.T) {
|
||||
tr := true
|
||||
|
||||
req := buildSetSSHConfigRequest("p", "u", &tr, nil)
|
||||
assert.Equal(t, "p", req.ProfileName)
|
||||
assert.Equal(t, "u", req.Username)
|
||||
if assert.NotNil(t, req.EnableSSHRoot) {
|
||||
assert.True(t, *req.EnableSSHRoot)
|
||||
}
|
||||
assert.Nil(t, req.DisableSSHAuth, "an unset flag must leave the daemon value untouched")
|
||||
}
|
||||
|
||||
func TestWantsDangerousSSH(t *testing.T) {
|
||||
origRoot, origAuth := enableSSHRoot, disableSSHAuth
|
||||
t.Cleanup(func() { enableSSHRoot, disableSSHAuth = origRoot, origAuth })
|
||||
|
||||
newCmd := func() *cobra.Command {
|
||||
enableSSHRoot, disableSSHAuth = false, false
|
||||
c := &cobra.Command{Use: "x"}
|
||||
c.Flags().BoolVar(&enableSSHRoot, enableSSHRootFlag, false, "")
|
||||
c.Flags().BoolVar(&disableSSHAuth, disableSSHAuthFlag, false, "")
|
||||
return c
|
||||
}
|
||||
|
||||
// wantsDangerousSSH fires only in the privileged direction.
|
||||
t.Run("enable root true is dangerous", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
require.NoError(t, c.Flags().Set(enableSSHRootFlag, "true"))
|
||||
assert.True(t, wantsDangerousSSH(c))
|
||||
})
|
||||
|
||||
t.Run("enable root false is not dangerous", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
require.NoError(t, c.Flags().Set(enableSSHRootFlag, "false"))
|
||||
assert.False(t, wantsDangerousSSH(c))
|
||||
})
|
||||
|
||||
t.Run("disable auth true is dangerous", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
require.NoError(t, c.Flags().Set(disableSSHAuthFlag, "true"))
|
||||
assert.True(t, wantsDangerousSSH(c))
|
||||
})
|
||||
|
||||
t.Run("disable auth false is not dangerous", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
require.NoError(t, c.Flags().Set(disableSSHAuthFlag, "false"))
|
||||
assert.False(t, wantsDangerousSSH(c))
|
||||
})
|
||||
|
||||
t.Run("nothing changed is not dangerous", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
assert.False(t, wantsDangerousSSH(c))
|
||||
})
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// isProcessPrivileged reports whether the current process token is elevated
|
||||
// (running as administrator / LocalSystem).
|
||||
func isProcessPrivileged() bool {
|
||||
return windows.GetCurrentProcessToken().IsElevated()
|
||||
}
|
||||
|
||||
// runElevated should re-run exe with args elevated via a UAC prompt
|
||||
// (ShellExecuteEx with the "runas" verb).
|
||||
//
|
||||
// TODO(ssh-elevation): implement ShellExecuteEx("runas") + wait for the child.
|
||||
func runElevated(_ string, _ []string) error {
|
||||
return fmt.Errorf("automatic privilege elevation is not yet implemented on Windows, re-run netbird as administrator")
|
||||
}
|
||||
@@ -46,7 +46,7 @@ var logoutCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
if _, err := daemonClient.Logout(ctx, req); err != nil {
|
||||
return fmt.Errorf("deregister: %v", err)
|
||||
return daemonCallError("deregister", err)
|
||||
}
|
||||
|
||||
cmd.Println("Deregistered successfully")
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
var ownerCmd = &cobra.Command{
|
||||
Use: "owner",
|
||||
Short: "Manage who may control the NetBird daemon",
|
||||
Long: `Manage the daemon-wide owners.
|
||||
|
||||
Owners are enforced on the daemon and stored in the service parameters. All owners
|
||||
may control the daemon and use the shared default profile (plus root/administrator),
|
||||
every other profile stays isolated to the user that created it. An unowned daemon
|
||||
is claimed by the first caller (trust-on-first-use).`,
|
||||
}
|
||||
|
||||
var ownerAddCmd = &cobra.Command{
|
||||
Use: "add <principal>",
|
||||
Short: "Add a daemon owner principal",
|
||||
Long: `Add a daemon-wide owner principal. Principals are typed:
|
||||
uid:1000 a Unix user ID
|
||||
gid:1000 a Unix group ID
|
||||
group:netbird-admins a Unix group name (resolved via NSS/getent)
|
||||
sid:S-1-5-21-... a Windows user or group SID
|
||||
|
||||
Requires root/administrator or an existing owner.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.AddOwner(ctx, &proto.AddOwnerRequest{Principal: args[0]}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Printf("Added daemon owner %q\n", args[0])
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ownerResetCmd = &cobra.Command{
|
||||
Use: "reset",
|
||||
Short: "Clear the daemon owner list (root/administrator only)",
|
||||
Long: `Clear the daemon-wide owner list, returning the daemon to the unowned
|
||||
state. The next caller then claims ownership (trust-on-first-use). Requires
|
||||
root/administrator.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.ResetOwner(ctx, &proto.ResetOwnerRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Println("Daemon owner list cleared, the next caller will claim ownership")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ownerShareCmd = &cobra.Command{
|
||||
Use: "share",
|
||||
Short: "Mark the daemon shared (any local user may control it)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Println("Daemon is now shared with all local users")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ownerUnshareCmd = &cobra.Command{
|
||||
Use: "unshare",
|
||||
Short: "Stop sharing the daemon (restrict to its owners)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Println("Daemon is no longer shared")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// withDaemon runs fn with a connected daemon client, handling setup and teardown.
|
||||
func withDaemon(cmd *cobra.Command, fn func(context.Context, proto.DaemonServiceClient) error) error {
|
||||
SetFlagsFromEnvVars(rootCmd)
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||
log.Errorf("failed initializing log %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
log.Errorf("failed to connect to service CLI interface %v", err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("close daemon connection: %v", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
return fn(ctx, proto.NewDaemonServiceClient(conn))
|
||||
}
|
||||
@@ -117,12 +117,10 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
||||
} else {
|
||||
fmt.Fprintln(tw, "NAME\tACTIVE")
|
||||
}
|
||||
anyActive := false
|
||||
for _, profile := range resp.Profiles {
|
||||
marker := ""
|
||||
if profile.IsActive {
|
||||
marker = "✓"
|
||||
anyActive = true
|
||||
}
|
||||
name := profilemanager.StripCtrlChars(profile.Name)
|
||||
id := profilemanager.ID(profile.Id)
|
||||
@@ -132,19 +130,7 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintf(tw, "%s\t%s\n", name, marker)
|
||||
}
|
||||
}
|
||||
if err := tw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// None of the caller's profiles is active: another user may hold the daemon.
|
||||
// Surface it so the empty ACTIVE column is not mistaken for "nothing active".
|
||||
if !anyActive {
|
||||
if active, aerr := daemonClient.GetActiveProfile(cmd.Context(), &proto.GetActiveProfileRequest{}); aerr == nil && active.GetUsername() != "" && !usernamesMatch(active.GetUsername(), currUser.Username) {
|
||||
cmd.Printf("\nActive profile belongs to another user: %s (user %s)\n",
|
||||
profilemanager.StripCtrlChars(active.GetProfileName()), active.GetUsername())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func addProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
@@ -21,7 +20,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
@@ -92,6 +90,7 @@ var (
|
||||
// Don't resolve for service commands — they create the socket, not connect to it.
|
||||
if !isServiceCmd(cmd) {
|
||||
daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr)
|
||||
daemonAddr = daddr.ResolveDaemonAddr(daemonAddr)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -144,7 +143,7 @@ func init() {
|
||||
|
||||
defaultDaemonAddr := "unix:///var/run/netbird.sock"
|
||||
if runtime.GOOS == "windows" {
|
||||
defaultDaemonAddr = windowsPipeDaemonAddr
|
||||
defaultDaemonAddr = daddr.WindowsPipeAddr
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
|
||||
@@ -173,11 +172,6 @@ func init() {
|
||||
rootCmd.AddCommand(profileCmd)
|
||||
rootCmd.AddCommand(exposeCmd)
|
||||
|
||||
rootCmd.AddCommand(ownerCmd)
|
||||
ownerCmd.AddCommand(ownerAddCmd, ownerResetCmd, ownerShareCmd, ownerUnshareCmd)
|
||||
|
||||
rootCmd.AddCommand(setSSHConfigCmd)
|
||||
|
||||
networksCMD.AddCommand(routesListCmd)
|
||||
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
||||
|
||||
@@ -270,32 +264,15 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
|
||||
return prefix + upper
|
||||
}
|
||||
|
||||
// daemonDialTarget returns the gRPC dial target and base options for the daemon
|
||||
// address, handling the npipe scheme (Windows named pipe, via a context dialer)
|
||||
// and unix/tcp.
|
||||
func daemonDialTarget(addr string) (string, []grpc.DialOption) {
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
target := strings.TrimPrefix(addr, "tcp://")
|
||||
if strings.HasPrefix(addr, "npipe://") {
|
||||
path := pipePath(strings.TrimPrefix(addr, "npipe://"))
|
||||
opts = append(opts, grpc.WithContextDialer(func(dialCtx context.Context, _ string) (net.Conn, error) {
|
||||
return dialNamedPipe(dialCtx, path)
|
||||
}))
|
||||
target = "passthrough:///netbird-daemon-pipe"
|
||||
}
|
||||
return target, opts
|
||||
}
|
||||
|
||||
// DialClientGRPCServer returns client connection to the daemon server.
|
||||
func DialClientGRPCServer(ctx context.Context, addr string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
|
||||
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
target, dialOpts := daemonDialTarget(addr)
|
||||
dialOpts = append(dialOpts, grpc.WithBlock())
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
target, opts := daddr.DialTarget(addr)
|
||||
opts = append(opts, grpc.WithBlock())
|
||||
|
||||
return grpc.DialContext(ctx, target, dialOpts...)
|
||||
return grpc.DialContext(ctx, target, opts...)
|
||||
}
|
||||
|
||||
// WithBackOff execute function in backoff cycle.
|
||||
|
||||
@@ -30,19 +30,18 @@ var (
|
||||
serviceEnvVars []string
|
||||
jsonSocket string
|
||||
enableJSONSocket bool
|
||||
// owners seeds the daemon-wide owner set at install time (--owner). At runtime
|
||||
// the daemon reads and writes owners in service.json directly.
|
||||
owners []string
|
||||
// daemonShared carries the persisted daemon shared flag across
|
||||
// install/reconfigure round-trips (set at runtime via `netbird owner share`).
|
||||
daemonShared bool
|
||||
)
|
||||
|
||||
type program struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
serv *grpc.Server
|
||||
jsonServ *http.Server
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
serv *grpc.Server
|
||||
jsonServ *http.Server
|
||||
// jsonClient is the gateway's own connection to the daemon. It is held so
|
||||
// shutting the gateway down also closes it: nothing else references it once
|
||||
// the handlers are registered, so its transport goroutines would otherwise
|
||||
// outlive the server.
|
||||
jsonClient *grpc.ClientConn
|
||||
jsonServMu sync.Mutex
|
||||
serverInstance *server.Server
|
||||
serverInstanceMu sync.Mutex
|
||||
@@ -60,8 +59,7 @@ func init() {
|
||||
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
|
||||
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
|
||||
serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket")
|
||||
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp|npipe]://[path|host:port|name]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
|
||||
serviceCmd.PersistentFlags().StringSliceVar(&owners, "owner", nil, "Principal(s) allowed to control the daemon and its default profile: uid:1000, gid:1000, group:netbird-admins (NSS), or sid:S-1-5-... (Windows). Repeatable. Other profiles stay isolated per user. To persist: netbird service install --owner uid:1000")
|
||||
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
|
||||
serviceEnvDesc := `Sets extra environment variables for the service. ` +
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
@@ -21,25 +22,6 @@ import (
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// daemonServerOptions installs peer-identity transport credentials and the
|
||||
// authorization interceptor on the daemon ipc if supported.
|
||||
func daemonServerOptions(network string, interceptor *ipcauth.Interceptor) []grpc.ServerOption {
|
||||
creds := ipcauth.NewTransportCredentials()
|
||||
if creds == nil {
|
||||
log.Warnf("daemon ipc has no peer-identity primitive on %s, per-caller authorization is disabled", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
if network == "tcp" {
|
||||
log.Warnf("daemon is listening on TCP (%s), peer identity cannot be authenticated over TCP, per-caller authorization is disabled", daemonAddr)
|
||||
return nil
|
||||
}
|
||||
return []grpc.ServerOption{
|
||||
grpc.Creds(creds),
|
||||
grpc.ChainUnaryInterceptor(interceptor.UnaryServerInterceptor()),
|
||||
grpc.ChainStreamInterceptor(interceptor.StreamServerInterceptor()),
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONSocketFlags() error {
|
||||
if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket {
|
||||
return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway")
|
||||
@@ -47,6 +29,31 @@ func validateJSONSocketFlags() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// daemonServerOptions installs the transport credentials that expose each
|
||||
// caller's kernel-authenticated identity to the handlers, which is what lets
|
||||
// the daemon require root/administrator for privileged operations.
|
||||
//
|
||||
// The handshake exchanges no bytes, so older CLI and UI binaries still
|
||||
// interoperate. Callers on a TCP socket carry no identity at all: the daemon
|
||||
// keeps serving them, and the privileged operations deny them, so a warning is
|
||||
// logged to make the loss of functionality visible.
|
||||
func daemonServerOptions(network string) []grpc.ServerOption {
|
||||
if network == "tcp" {
|
||||
log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
|
||||
"so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+
|
||||
"deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
creds := ipcauth.NewTransportCredentials()
|
||||
if creds == nil {
|
||||
log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
return []grpc.ServerOption{grpc.Creds(creds)}
|
||||
}
|
||||
|
||||
func (p *program) Start(svc service.Service) error {
|
||||
// Start should not block. Do the actual work async.
|
||||
log.Info("starting NetBird service") //nolint
|
||||
@@ -58,12 +65,11 @@ func (p *program) Start(svc service.Service) error {
|
||||
// Collect static system and platform information
|
||||
system.UpdateStaticInfoAsync()
|
||||
|
||||
// A daemon installed before named-pipe support uses the old loopback-TCP
|
||||
// address as the daemon address. We migrate to a named pipe so an
|
||||
// upgraded daemon enforces per-caller authorization instead of silently
|
||||
// running on identity-less TCP.
|
||||
if migrated, ok := migrateLegacyDaemonAddr(daemonAddr); ok {
|
||||
log.Infof("legacy daemon address %q predates named-pipe support. listening on %q so per-caller authorization is enforced", daemonAddr, migrated)
|
||||
// A daemon installed before named-pipe support has the loopback TCP address
|
||||
// persisted. Move it to the named pipe so an upgraded daemon can identify
|
||||
// its callers instead of silently serving an unauthenticated socket.
|
||||
if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
|
||||
log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated)
|
||||
daemonAddr = migrated
|
||||
}
|
||||
|
||||
@@ -72,79 +78,93 @@ func (p *program) Start(svc service.Service) error {
|
||||
return fmt.Errorf("parse daemon address: %w", err)
|
||||
}
|
||||
|
||||
// Owner-authorization interceptor. The ConfigAdapter is a lazy bridge: the
|
||||
// gRPC server is built before the daemon server instance exists, so we set
|
||||
// the real policy backend below once serverInstance is created.
|
||||
ownerAdapter := &ipcauth.ConfigAdapter{}
|
||||
authInterceptor := ipcauth.NewInterceptor(ownerAdapter, ipcauth.NewDefaultGroupResolver())
|
||||
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
|
||||
p.serv = grpc.NewServer(daemonServerOptions(network)...)
|
||||
|
||||
// in any case, even if configuration does not exist we run daemon to serve the CLI gRPC API.
|
||||
p.serv = grpc.NewServer(daemonServerOptions(network, authInterceptor)...)
|
||||
|
||||
daemonListener, err := listenOnAddress(daemonAddr)
|
||||
daemonListener, jsonListener, err := listenDaemonSockets()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen daemon interface: %w", err)
|
||||
}
|
||||
|
||||
var jsonListener *socketListener
|
||||
if enableJSONSocket {
|
||||
jsonListener, err = listenOnAddress(jsonSocket)
|
||||
if err != nil {
|
||||
_ = daemonListener.Close()
|
||||
return fmt.Errorf("listen daemon JSON interface: %w", err)
|
||||
}
|
||||
} else {
|
||||
removeStaleUnixSocketForAddress(jsonSocket)
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer daemonListener.Close()
|
||||
if jsonListener != nil {
|
||||
defer jsonListener.Close()
|
||||
}
|
||||
|
||||
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
if jsonListener != nil {
|
||||
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
|
||||
// Daemon-wide owners live in service.json (governs the default profile and
|
||||
// daemon access), wire persistence before serving so owner add / TOFU work.
|
||||
serverInstance.SetDaemonOwnerStore(daemonOwnerStore{})
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
log.Fatalf("failed to start daemon: %v", err)
|
||||
}
|
||||
ownerAdapter.SetBackend(serverInstance)
|
||||
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
|
||||
|
||||
p.serverInstanceMu.Lock()
|
||||
p.serverInstance = serverInstance
|
||||
p.serverInstanceMu.Unlock()
|
||||
|
||||
if jsonListener != nil {
|
||||
log.Warnf("JSON gateway (--enable-json-socket) re-dials the daemon locally. The HTTP client's identity is forwarded so per-caller authorization still applies, but restrict access to %s appropriately", jsonSocket)
|
||||
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
||||
log.Fatalf("failed to start daemon JSON server: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Debug("daemon JSON socket disabled")
|
||||
}
|
||||
|
||||
log.Printf("started daemon server: %v", daemonListener.address)
|
||||
if err := p.serv.Serve(daemonListener.Listener); err != nil {
|
||||
log.Errorf("failed to serve daemon requests: %v", err)
|
||||
// Fatal here rather than inside serve, so serve's deferred listener
|
||||
// closes run before the process exits.
|
||||
if err := p.serve(daemonListener, jsonListener); err != nil {
|
||||
log.Fatalf("failed to %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// listenDaemonSockets opens the daemon control socket and, when it is enabled, the
|
||||
// JSON gateway socket. The control socket is closed again if the second one fails,
|
||||
// so a failed start leaves nothing listening. The returned JSON listener is nil
|
||||
// when the socket is disabled.
|
||||
func listenDaemonSockets() (*socketListener, *socketListener, error) {
|
||||
daemonListener, err := listenOnAddress(daemonAddr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("listen daemon interface: %w", err)
|
||||
}
|
||||
|
||||
if !enableJSONSocket {
|
||||
removeStaleUnixSocketForAddress(jsonSocket)
|
||||
return daemonListener, nil, nil
|
||||
}
|
||||
|
||||
jsonListener, err := listenOnAddress(jsonSocket)
|
||||
if err != nil {
|
||||
if cerr := daemonListener.Close(); cerr != nil {
|
||||
log.Debugf("close daemon listener: %v", cerr)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err)
|
||||
}
|
||||
|
||||
return daemonListener, jsonListener, nil
|
||||
}
|
||||
|
||||
// serve brings up the daemon server on an already-open control socket and blocks
|
||||
// until it stops. jsonListener is nil when the JSON socket is disabled. A returned
|
||||
// error means the daemon cannot run at all and the caller is expected to exit; the
|
||||
// failures it recovers from on its own are logged here.
|
||||
func (p *program) serve(daemonListener, jsonListener *socketListener) error {
|
||||
defer daemonListener.Close()
|
||||
if jsonListener != nil {
|
||||
defer jsonListener.Close()
|
||||
}
|
||||
|
||||
// chmodUnixSocket is a no-op for a nil listener and for a non-unix one.
|
||||
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
|
||||
log.Error(err)
|
||||
return nil
|
||||
}
|
||||
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
|
||||
log.Error(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
}
|
||||
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
|
||||
|
||||
p.serverInstanceMu.Lock()
|
||||
p.serverInstance = serverInstance
|
||||
p.serverInstanceMu.Unlock()
|
||||
|
||||
if jsonListener == nil {
|
||||
log.Debug("daemon JSON socket disabled")
|
||||
} else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
||||
return fmt.Errorf("start daemon JSON server: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("started daemon server: %v", daemonListener.address)
|
||||
if err := p.serv.Serve(daemonListener.Listener); err != nil {
|
||||
log.Errorf("failed to serve daemon requests: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *program) Stop(srv service.Service) error {
|
||||
p.serverInstanceMu.Lock()
|
||||
if p.serverInstance != nil {
|
||||
@@ -159,8 +179,13 @@ func (p *program) Stop(srv service.Service) error {
|
||||
p.cancel()
|
||||
|
||||
p.jsonServMu.Lock()
|
||||
jsonServ := p.jsonServ
|
||||
jsonServ, jsonClient := p.jsonServ, p.jsonClient
|
||||
p.jsonServMu.Unlock()
|
||||
if jsonClient != nil {
|
||||
if err := jsonClient.Close(); err != nil {
|
||||
log.Debugf("close daemon JSON gateway client: %v", err)
|
||||
}
|
||||
}
|
||||
if jsonServ != nil {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
if err := jsonServ.Shutdown(shutdownCtx); err != nil {
|
||||
|
||||
@@ -8,59 +8,120 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// jsonPeerCtxKey keys the HTTP client's kernel identity in the request context.
|
||||
type jsonPeerCtxKey struct{}
|
||||
// jsonPeerIdentity is the context key under which the connecting HTTP client's
|
||||
// identity is stashed for the lifetime of its connection.
|
||||
type jsonPeerIdentity struct{}
|
||||
|
||||
// jsonConnContext reads the connecting HTTP client's identity from the JSON
|
||||
// socket and stashes it so it can be forwarded to the daemon. The gateway
|
||||
// re-dials the daemon as the daemon's own identity, so without this the
|
||||
// daemon would see every JSON request as privileged.
|
||||
func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
|
||||
id, err := ipcauth.ConnIdentity(c)
|
||||
if err != nil {
|
||||
log.Warnf("json gateway: cannot read HTTP client identity, requests won't carry it: %v", err)
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, jsonPeerCtxKey{}, id)
|
||||
// jsonPeerIdentityValue pairs the identity with whether it could be read at
|
||||
// all, so an unreadable identity is forwarded as "unknown" rather than omitted.
|
||||
type jsonPeerIdentityValue struct {
|
||||
id ipcauth.Identity
|
||||
known bool
|
||||
}
|
||||
|
||||
// jsonForwardIdentity injects the stashed HTTP client identity as gRPC metadata
|
||||
// on the gateway's re-dial to the daemon. The daemon trusts it only because the
|
||||
// dial arrives as the daemon's own (self/privileged) identity.
|
||||
func jsonForwardIdentity(ctx context.Context, _ *http.Request) metadata.MD {
|
||||
id, ok := ctx.Value(jsonPeerCtxKey{}).(ipcauth.Identity)
|
||||
if !ok {
|
||||
return nil
|
||||
// jsonConnContext reads the identity of the client connecting to the JSON
|
||||
// socket and stashes it on the connection's context. The gateway re-dials the
|
||||
// daemon in-process, so the daemon would otherwise see every JSON request as
|
||||
// coming from the daemon itself.
|
||||
func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
|
||||
value := jsonPeerIdentityValue{}
|
||||
id, err := ipcauth.ConnIdentity(c)
|
||||
if err != nil {
|
||||
log.Warnf("json gateway: cannot read HTTP client identity, privileged operations will be denied for this connection: %v", err)
|
||||
} else {
|
||||
value.id = id
|
||||
value.known = true
|
||||
}
|
||||
return ipcauth.ForwardIdentityMetadata(id)
|
||||
return context.WithValue(ctx, jsonPeerIdentity{}, value)
|
||||
}
|
||||
|
||||
// forwardIdentity stamps the HTTP client's identity onto every call the gateway
|
||||
// makes to the daemon.
|
||||
//
|
||||
// It is an interceptor on the gateway's client connection rather than a
|
||||
// runtime.WithMetadata annotator because grpc-gateway skips annotators when no
|
||||
// request header maps to metadata, which an HTTP/1.0 request with no Host header
|
||||
// over a unix socket achieves. The daemon would then receive no marker, see its own
|
||||
// identity as the transport peer, and authorize the request as the daemon itself.
|
||||
// An interceptor runs for every RPC whatever the request looked like.
|
||||
func forwardIdentity(ctx context.Context) context.Context {
|
||||
value, ok := ctx.Value(jsonPeerIdentity{}).(jsonPeerIdentityValue)
|
||||
if !ok {
|
||||
// No ConnContext ran for this request, so forward an unknown identity:
|
||||
// the daemon must not mistake its own identity for the client's.
|
||||
return ipcauth.WithForwardedIdentity(ctx, ipcauth.Identity{}, false)
|
||||
}
|
||||
return ipcauth.WithForwardedIdentity(ctx, value.id, value.known)
|
||||
}
|
||||
|
||||
func forwardIdentityUnary(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
return invoker(forwardIdentity(ctx), method, req, reply, cc, opts...)
|
||||
}
|
||||
|
||||
func forwardIdentityStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
return streamer(forwardIdentity(ctx), desc, cc, method, opts...)
|
||||
}
|
||||
|
||||
// reservedHeaderWarning limits the dropped-header warning to the first occurrence.
|
||||
var reservedHeaderWarning sync.Once
|
||||
|
||||
// jsonIncomingHeaderMatcher keeps an HTTP client from supplying the metadata the
|
||||
// gateway uses to forward its identity. grpc-gateway turns "Grpc-Metadata-<key>"
|
||||
// headers into gRPC metadata and joins them ahead of what its annotators add, so
|
||||
// without this filter a JSON client could send its own x-netbird-fwd-uid and the
|
||||
// daemon would authorize that instead of the client's real identity.
|
||||
func jsonIncomingHeaderMatcher(key string) (string, bool) {
|
||||
mapped, ok := runtime.DefaultHeaderMatcher(key)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if ipcauth.IsReservedForwardKey(mapped) {
|
||||
// Warn once: any client can send these on every request, so warning each
|
||||
// time hands it a way to fill the log. The rest are debug-level.
|
||||
reservedHeaderWarning.Do(func() {
|
||||
log.Warnf("json gateway: dropping reserved header %q from a request: only the gateway may set the caller's identity", key)
|
||||
})
|
||||
log.Debugf("json gateway: dropping reserved header %q", key)
|
||||
return "", false
|
||||
}
|
||||
return mapped, true
|
||||
}
|
||||
|
||||
func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
|
||||
if jsonListener.network == "tcp" {
|
||||
log.Warnf("json daemon is listening on TCP (%s), peer identity cannot be authenticated over TCP, per-caller authorization is disabled", daemonAddr)
|
||||
log.Warnf("daemon JSON socket is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
|
||||
"so privileged operations will be denied for JSON clients", jsonListener.address)
|
||||
}
|
||||
|
||||
mux := runtime.NewServeMux(runtime.WithMetadata(jsonForwardIdentity))
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
|
||||
// Lazy client to the daemon, npipe-aware (grpc.NewClient does not connect
|
||||
// until the first request, so this does not block startup before Serve).
|
||||
target, opts := daemonDialTarget(daemonEndpoint)
|
||||
// grpc.NewClient does not connect until the first request, so registering
|
||||
// the handler here cannot block daemon startup.
|
||||
target, opts := daemonaddr.DialTarget(daemonEndpoint)
|
||||
opts = append(opts,
|
||||
grpc.WithChainUnaryInterceptor(forwardIdentityUnary),
|
||||
grpc.WithChainStreamInterceptor(forwardIdentityStream),
|
||||
)
|
||||
conn, err := grpc.NewClient(target, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create daemon client for JSON gateway: %w", err)
|
||||
}
|
||||
if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("close daemon client after failed JSON gateway registration: %v", cerr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -75,6 +136,7 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
|
||||
|
||||
p.jsonServMu.Lock()
|
||||
p.jsonServ = jsonServer
|
||||
p.jsonClient = conn
|
||||
p.jsonServMu.Unlock()
|
||||
|
||||
go func() {
|
||||
|
||||
261
client/cmd/service_json_gateway_test.go
Normal file
261
client/cmd/service_json_gateway_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
//go:build !windows && !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// The JSON gateway runs inside the daemon and re-dials it locally, so every JSON
|
||||
// request reaches a handler with the daemon's own identity as the transport peer.
|
||||
// The gateway therefore forwards its HTTP client's identity as metadata, and the
|
||||
// daemon authorizes that instead of itself. These tests drive the real wiring
|
||||
// (jsonConnContext, forwardIdentity, jsonIncomingHeaderMatcher) and check the
|
||||
// identity a handler would end up authorizing.
|
||||
|
||||
// daemonSideCtx is what a handler sees for a gateway-relayed call. The transport
|
||||
// peer must be this process's own identity: the gateway is the daemon, so the two
|
||||
// cannot differ, and hardcoding root here instead would describe a state that
|
||||
// never occurs.
|
||||
func daemonSideCtx(t *testing.T, md metadata.MD) context.Context {
|
||||
t.Helper()
|
||||
self, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read this process's identity: %v", err)
|
||||
}
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: ipcauth.AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: self,
|
||||
},
|
||||
})
|
||||
return metadata.NewIncomingContext(ctx, md)
|
||||
}
|
||||
|
||||
// gatewayMetadata reproduces what the daemon receives for a JSON request: the
|
||||
// mux annotates the context from the request's headers, then the interceptor on the
|
||||
// gateway's client connection stamps the caller's identity. The order matters,
|
||||
// since the interceptor must win over anything a header put there.
|
||||
func gatewayMetadata(t *testing.T, req *http.Request, ctx context.Context) metadata.MD {
|
||||
t.Helper()
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
annotated, err := runtime.AnnotateContext(ctx, mux, req,
|
||||
"/daemon.DaemonService/SetConfig",
|
||||
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
|
||||
if err != nil {
|
||||
t.Fatalf("annotate: %v", err)
|
||||
}
|
||||
|
||||
md, ok := metadata.FromOutgoingContext(forwardIdentity(annotated))
|
||||
if !ok {
|
||||
t.Fatal("the interceptor produced no metadata")
|
||||
}
|
||||
return md
|
||||
}
|
||||
|
||||
// clientCtx is the connection context jsonConnContext would have produced for an
|
||||
// HTTP client whose identity the gateway could read.
|
||||
func clientCtx(id ipcauth.Identity, known bool) context.Context {
|
||||
return context.WithValue(context.Background(), jsonPeerIdentity{},
|
||||
jsonPeerIdentityValue{id: id, known: known})
|
||||
}
|
||||
|
||||
// An HTTP client must not be able to name its own identity. grpc-gateway turns
|
||||
// Grpc-Metadata-<key> headers into gRPC metadata, so without the header filter and
|
||||
// the interceptor overwriting the reserved keys, this request would authorize as
|
||||
// uid 0.
|
||||
func TestJSONGateway_ForgedIdentityHeaderIsDropped(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Uid", "0")
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Gid", "0")
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd", "1")
|
||||
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Sid", "S-1-5-18")
|
||||
|
||||
caller := ipcauth.Identity{UID: 31000, GID: 31000}
|
||||
md := gatewayMetadata(t, req, clientCtx(caller, true))
|
||||
|
||||
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
|
||||
if !ok {
|
||||
t.Fatal("the forwarded identity should be usable")
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
t.Errorf("forged header was believed: authorized as %v", id)
|
||||
}
|
||||
if id.UID != caller.UID {
|
||||
t.Errorf("authorized as uid %d, want the real client %d", id.UID, caller.UID)
|
||||
}
|
||||
}
|
||||
|
||||
// A request with no headers at all (HTTP/1.0 needs no Host, and a unix socket
|
||||
// yields no host:port) makes grpc-gateway produce no metadata whatsoever and skip
|
||||
// its annotators: "if len(pairs) == 0 { return ctx, nil, nil }" in
|
||||
// runtime/context.go. That is why the identity is stamped by an interceptor
|
||||
// instead. This is the case that previously reached the gate as the daemon itself.
|
||||
func TestJSONGateway_HeaderlessRequestIsStillMarkedForwarded(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header = http.Header{}
|
||||
req.Host = ""
|
||||
|
||||
caller := ipcauth.Identity{UID: 31000, GID: 31000}
|
||||
ctx := clientCtx(caller, true)
|
||||
|
||||
// Pin the skip path itself: if grpc-gateway ever produced a pair here, this
|
||||
// test would still pass below while no longer covering what it was written for.
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
annotated, err := runtime.AnnotateContext(ctx, mux, req,
|
||||
"/daemon.DaemonService/SetConfig",
|
||||
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
|
||||
if err != nil {
|
||||
t.Fatalf("annotate: %v", err)
|
||||
}
|
||||
if md, ok := metadata.FromOutgoingContext(annotated); ok {
|
||||
t.Fatalf("grpc-gateway produced metadata %v for a headerless request; "+
|
||||
"this test no longer covers the annotator-skip path", md)
|
||||
}
|
||||
|
||||
md := gatewayMetadata(t, req, ctx)
|
||||
|
||||
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
|
||||
if !ok {
|
||||
t.Fatal("the forwarded identity should be usable")
|
||||
}
|
||||
if id.UID != caller.UID || id.IsPrivileged() {
|
||||
t.Errorf("authorized as %v, want the real client uid %d", id, caller.UID)
|
||||
}
|
||||
}
|
||||
|
||||
// When the gateway cannot read its client's identity (a TCP JSON socket, say) it
|
||||
// forwards the marker alone. The daemon must then report "unidentified" so the
|
||||
// privileged operations refuse, rather than falling back to the gateway's own
|
||||
// identity.
|
||||
func TestJSONGateway_UnreadableClientIdentityIsUnidentified(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
md := gatewayMetadata(t, req, clientCtx(ipcauth.Identity{}, false))
|
||||
|
||||
if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
|
||||
t.Errorf("a request with no client identity was authorized as %v", id)
|
||||
}
|
||||
}
|
||||
|
||||
// A request that never passed through jsonConnContext (no stashed identity) must
|
||||
// also come out unidentified rather than as the daemon.
|
||||
func TestJSONGateway_MissingConnContextIsUnidentified(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
md := gatewayMetadata(t, req, context.Background())
|
||||
|
||||
if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
|
||||
t.Errorf("a request with no connection context was authorized as %v", id)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end over a real unix socket: the gateway reads the connecting client's
|
||||
// identity from the socket itself, so a client cannot present anything else.
|
||||
func TestJSONGateway_IdentityComesFromTheSocket(t *testing.T) {
|
||||
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
|
||||
|
||||
type observed struct {
|
||||
md metadata.MD
|
||||
}
|
||||
seen := make(chan observed, 1)
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, err := runtime.AnnotateContext(r.Context(), mux, r,
|
||||
"/daemon.DaemonService/SetConfig",
|
||||
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
|
||||
if err != nil {
|
||||
t.Errorf("annotate: %v", err)
|
||||
return
|
||||
}
|
||||
md, _ := metadata.FromOutgoingContext(forwardIdentity(ctx))
|
||||
seen <- observed{md: md}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ConnContext: jsonConnContext,
|
||||
}
|
||||
|
||||
sock := filepath.Join(t.TempDir(), "http.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := srv.Close(); err != nil {
|
||||
t.Logf("close server: %v", err)
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
t.Logf("serve: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
conn, err := net.Dial("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Logf("close conn: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Forge the identity headers on the wire as well.
|
||||
request := "POST /daemon.DaemonService/SetConfig HTTP/1.1\r\n" +
|
||||
"Host: localhost\r\n" +
|
||||
"Grpc-Metadata-X-Netbird-Fwd: 1\r\n" +
|
||||
"Grpc-Metadata-X-Netbird-Fwd-Uid: 0\r\n" +
|
||||
"Content-Length: 0\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-seen:
|
||||
self, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read this process's identity: %v", err)
|
||||
}
|
||||
// The socket peer is this test process, so that is the identity the
|
||||
// gateway must forward, not the uid 0 the request asked for.
|
||||
if uids := got.md.Get("x-netbird-fwd-uid"); len(uids) != 1 {
|
||||
t.Fatalf("x-netbird-fwd-uid = %v, want exactly the gateway's own value", uids)
|
||||
}
|
||||
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, got.md))
|
||||
if !ok {
|
||||
t.Fatal("the forwarded identity should be usable")
|
||||
}
|
||||
if id.UID != self.UID {
|
||||
t.Errorf("authorized as uid %d, want the socket peer %d", id.UID, self.UID)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the gateway never handled the request")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/configs"
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -33,15 +34,6 @@ type serviceParams struct {
|
||||
DisableNetworks bool `json:"disable_networks,omitempty"`
|
||||
EnableJSONSocket bool `json:"enable_json_socket,omitempty"`
|
||||
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
|
||||
// Owners lists the principals allowed to control this profile over the local
|
||||
// IPC, as typed strings: "uid:1000", "gid:1000", "group:netbird-admins"
|
||||
// (Unix, NSS-resolved) or "sid:S-1-5-..." (Windows user or group SID). Empty
|
||||
// with Shared=false means the profile is owned by nobody yet, until claimed
|
||||
Owners []string `json:"owners,omitempty"`
|
||||
|
||||
// Shared, when true, lets any authenticated local caller control this profile
|
||||
// (opt-in). Takes precedence over Owners.
|
||||
Shared bool `json:"shared,omitempty"`
|
||||
}
|
||||
|
||||
// serviceParamsPath returns the path to the service params file.
|
||||
@@ -49,38 +41,6 @@ func serviceParamsPath() string {
|
||||
return filepath.Join(configs.StateDir, serviceParamsFile)
|
||||
}
|
||||
|
||||
// daemonOwnerStore persists the daemon-wide owner set in service.json. It
|
||||
// implements server.DaemonOwnerStore so the daemon can read owners at startup and
|
||||
// mutate them at runtime (owner add, reset, share, TOFU claim) without server
|
||||
// importing cmd. Load-modify-write preserves the other service.json fields.
|
||||
type daemonOwnerStore struct{}
|
||||
|
||||
func (daemonOwnerStore) Load() ([]string, bool, error) {
|
||||
params, err := loadServiceParams()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if params == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
return params.Owners, params.Shared, nil
|
||||
}
|
||||
|
||||
func (daemonOwnerStore) Save(owners []string, shared bool) error {
|
||||
params, err := loadServiceParams()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if params == nil {
|
||||
// No service.json yet (daemon started without `service install`). Seed it
|
||||
// from the running daemon's current parameters so the file stays complete.
|
||||
params = currentServiceParams()
|
||||
}
|
||||
params.Owners = owners
|
||||
params.Shared = shared
|
||||
return saveServiceParams(params)
|
||||
}
|
||||
|
||||
// loadServiceParams reads saved service parameters from disk.
|
||||
// Returns nil with no error if the file does not exist.
|
||||
func loadServiceParams() (*serviceParams, error) {
|
||||
@@ -127,8 +87,6 @@ func currentServiceParams() *serviceParams {
|
||||
EnableCapture: captureEnabled,
|
||||
DisableNetworks: networksDisabled,
|
||||
EnableJSONSocket: enableJSONSocket,
|
||||
Owners: owners,
|
||||
Shared: daemonShared,
|
||||
}
|
||||
|
||||
if len(serviceEnvVars) > 0 {
|
||||
@@ -168,8 +126,11 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
|
||||
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
|
||||
daemonAddr = params.DaemonAddr
|
||||
if migrated, ok := migrateLegacyDaemonAddr(daemonAddr); ok {
|
||||
cmd.Printf("Migrating saved daemon address %q to %q so per-caller authorization can be enforced\n", daemonAddr, migrated)
|
||||
// An install that predates named-pipe support has the loopback TCP
|
||||
// address saved. Callers carry no identity over TCP, so move it to the
|
||||
// pipe instead of restoring a socket the daemon cannot authorize on.
|
||||
if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
|
||||
cmd.Printf("Moving the saved daemon address from %s to %s so the daemon can identify its callers\n", daemonAddr, migrated)
|
||||
daemonAddr = migrated
|
||||
}
|
||||
}
|
||||
@@ -212,14 +173,6 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
networksDisabled = params.DisableNetworks
|
||||
}
|
||||
|
||||
// Carry the daemon-wide owner set forward across install/reconfigure so a
|
||||
// runtime owner add or TOFU claim in service.json is not clobbered. --owner
|
||||
// overrides.
|
||||
if !serviceCmd.PersistentFlags().Changed("owner") && len(params.Owners) > 0 {
|
||||
owners = params.Owners
|
||||
}
|
||||
daemonShared = params.Shared
|
||||
|
||||
applyServiceEnvParams(cmd, params)
|
||||
}
|
||||
|
||||
|
||||
@@ -431,15 +431,9 @@ func TestServiceParams_BuildArgsCoversAllFlags(t *testing.T) {
|
||||
installerFile, err := parser.ParseFile(fset, "service_installer.go", nil, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fields that are handled outside of buildServiceArguments.
|
||||
// - ServiceEnvVars flows through newSVCConfig() EnvVars, not CLI args.
|
||||
// - Owners/Shared are daemon-wide ownership persisted in service.json and
|
||||
// read+mutated by the daemon at runtime (owner add / TOFU claim); they are
|
||||
// deliberately NOT baked into the run args so runtime changes are not lost.
|
||||
// Fields that are handled outside of buildServiceArguments (env vars go through newSVCConfig).
|
||||
fieldsNotInArgs := map[string]bool{
|
||||
"ServiceEnvVars": true,
|
||||
"Owners": true,
|
||||
"Shared": true,
|
||||
}
|
||||
|
||||
buildFields := extractFuncGlobalRefs(t, installerFile, "buildServiceArguments")
|
||||
|
||||
@@ -3,18 +3,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// listenNamedPipe is unsupported off Windows; named pipes are a Windows-only transport.
|
||||
func listenNamedPipe(string) (net.Listener, error) {
|
||||
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// dialNamedPipe is unsupported off Windows.
|
||||
func dialNamedPipe(context.Context, string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
|
||||
// listenNamedPipe is Windows-only: no other platform serves the daemon on a
|
||||
// named pipe.
|
||||
func listenNamedPipe(string) (net.Listener, string, error) {
|
||||
return nil, "", fmt.Errorf("named pipes are only supported on Windows")
|
||||
}
|
||||
|
||||
@@ -3,28 +3,39 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// listenNamedPipe creates the daemon control named pipe with a permissive,
|
||||
// local-only SDDL. Any local caller may connect, like Unix socket with 0666.
|
||||
func listenNamedPipe(path string) (net.Listener, error) {
|
||||
return winio.ListenPipe(path, &winio.PipeConfig{
|
||||
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
|
||||
})
|
||||
}
|
||||
// listenNamedPipe creates the daemon control pipe and reports the path it ended
|
||||
// up on. The security descriptor lets any local caller connect, as a Unix socket
|
||||
// at 0666 does, and the privileged operations are authorized separately from the
|
||||
// caller's token.
|
||||
//
|
||||
// The protected name comes first so that an unprivileged process cannot take the
|
||||
// name before the service does. Creating it requires being an administrator or
|
||||
// LocalSystem, so a daemon an ordinary user runs themselves, as in netstack mode,
|
||||
// falls back to the plain name; clients try both and check who serves them.
|
||||
func listenNamedPipe(name string) (net.Listener, string, error) {
|
||||
var errs []error
|
||||
for _, path := range daemonaddr.PipePaths(name) {
|
||||
listener, err := winio.ListenPipe(path, &winio.PipeConfig{
|
||||
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debugf("not serving the daemon on %s: %v", path, err)
|
||||
errs = append(errs, fmt.Errorf("%s: %w", path, err))
|
||||
continue
|
||||
}
|
||||
return listener, path, nil
|
||||
}
|
||||
|
||||
// dialNamedPipe connects to the daemon ipc named pipe at SECURITY_IDENTIFICATION.
|
||||
func dialNamedPipe(ctx context.Context, path string) (net.Conn, error) {
|
||||
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
|
||||
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the
|
||||
// daemon cannot read the caller's token. Identification lets the daemon
|
||||
// read its SID/groups without granting it the ability to act as the caller.
|
||||
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
|
||||
return nil, "", errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -15,30 +14,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
windowsPipeDaemonAddr = "npipe://netbird"
|
||||
|
||||
// legacyWindowsDaemonAddr is the loopback-TCP address the Windows daemon used
|
||||
// before named-pipe support.
|
||||
legacyWindowsDaemonAddr = "tcp://127.0.0.1:41731"
|
||||
)
|
||||
|
||||
// migrateLegacyDaemonAddr upgrades the pre-named-pipe Windows daemon address to
|
||||
// the pipe. Existing installs persist daemon addr, so on upgrade the daemon
|
||||
// would otherwise keep listening on TCP and silently run without IPC
|
||||
// authorization. Only the exact legacy default is rewritten, while a
|
||||
// deliberately-chosen custom TCP address is left alone.
|
||||
func migrateLegacyDaemonAddr(addr string) (string, bool) {
|
||||
return migrateLegacyDaemonAddrForOS(runtime.GOOS, addr)
|
||||
}
|
||||
|
||||
func migrateLegacyDaemonAddrForOS(goos, addr string) (string, bool) {
|
||||
if goos == "windows" && addr == legacyWindowsDaemonAddr {
|
||||
return windowsPipeDaemonAddr, true
|
||||
}
|
||||
return addr, false
|
||||
}
|
||||
|
||||
type socketListener struct {
|
||||
net.Listener
|
||||
network string
|
||||
@@ -52,8 +27,7 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
||||
}
|
||||
|
||||
if network == "npipe" {
|
||||
path := pipePath(address)
|
||||
listener, err := listenNamedPipe(path)
|
||||
listener, path, err := listenNamedPipe(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -86,15 +60,6 @@ func parseListenAddress(addr string) (string, string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// pipePath maps a daemon-addr npipe name ("npipe://netbird") to a Windows
|
||||
// named-pipe path (\\.\pipe\netbird).
|
||||
func pipePath(name string) string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return name
|
||||
}
|
||||
return `\\.\pipe\` + name
|
||||
}
|
||||
|
||||
func removeStaleUnixSocket(path string) {
|
||||
stat, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
//go:build !ios && !android
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMigrateLegacyDaemonAddrForOS(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
goos string
|
||||
addr string
|
||||
want string
|
||||
migrate bool
|
||||
}{
|
||||
{
|
||||
name: "windows legacy tcp migrates to pipe",
|
||||
goos: "windows",
|
||||
addr: legacyWindowsDaemonAddr,
|
||||
want: windowsPipeDaemonAddr,
|
||||
migrate: true,
|
||||
},
|
||||
{
|
||||
name: "windows pipe already migrated stays",
|
||||
goos: "windows",
|
||||
addr: windowsPipeDaemonAddr,
|
||||
want: windowsPipeDaemonAddr,
|
||||
migrate: false,
|
||||
},
|
||||
{
|
||||
name: "windows custom tcp left alone",
|
||||
goos: "windows",
|
||||
addr: "tcp://127.0.0.1:9999",
|
||||
want: "tcp://127.0.0.1:9999",
|
||||
migrate: false,
|
||||
},
|
||||
{
|
||||
name: "linux legacy-looking tcp not migrated",
|
||||
goos: "linux",
|
||||
addr: legacyWindowsDaemonAddr,
|
||||
want: legacyWindowsDaemonAddr,
|
||||
migrate: false,
|
||||
},
|
||||
{
|
||||
name: "linux unix socket untouched",
|
||||
goos: "linux",
|
||||
addr: "unix:///var/run/netbird.sock",
|
||||
want: "unix:///var/run/netbird.sock",
|
||||
migrate: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := migrateLegacyDaemonAddrForOS(tc.goos, tc.addr)
|
||||
assert.Equal(t, tc.want, got)
|
||||
assert.Equal(t, tc.migrate, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
var (
|
||||
setSSHConfigProfile string
|
||||
setSSHConfigUsername string
|
||||
setSSHConfigEnableRoot bool
|
||||
setSSHConfigDisableAuth bool
|
||||
)
|
||||
|
||||
// setSSHConfigCmd applies the privileged SSH settings (enable root login /
|
||||
// disable auth) to a profile over the daemon IPC. Hidden because users
|
||||
// interact via `netbird up` / the UI, not directly.
|
||||
var setSSHConfigCmd = &cobra.Command{
|
||||
Use: SetSSHConfigCmdName,
|
||||
Short: "Apply privileged SSH settings to a profile (internal elevation target)",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := internal.CtxInitState(cmd.Context())
|
||||
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to daemon: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Warnf("failed closing daemon gRPC client connection: %v", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
var enableRoot, disableAuth *bool
|
||||
if cmd.Flags().Changed(enableSSHRootFlag) {
|
||||
enableRoot = &setSSHConfigEnableRoot
|
||||
}
|
||||
if cmd.Flags().Changed(disableSSHAuthFlag) {
|
||||
disableAuth = &setSSHConfigDisableAuth
|
||||
}
|
||||
|
||||
req := buildSetSSHConfigRequest(setSSHConfigProfile, setSSHConfigUsername, enableRoot, disableAuth)
|
||||
if _, err := proto.NewDaemonServiceClient(conn).SetConfig(ctx, req); err != nil {
|
||||
return fmt.Errorf("apply SSH config: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildSetSSHConfigRequest builds a minimal SetConfigRequest that touches only
|
||||
// the SSH fields that were provided (nil pointers leave the daemon's stored
|
||||
// value untouched, matching setupSetConfigReq's pointer semantics).
|
||||
func buildSetSSHConfigRequest(profileName, username string, enableSSHRoot, disableSSHAuth *bool) *proto.SetConfigRequest {
|
||||
return &proto.SetConfigRequest{
|
||||
ProfileName: profileName,
|
||||
Username: username,
|
||||
EnableSSHRoot: enableSSHRoot,
|
||||
DisableSSHAuth: disableSSHAuth,
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
setSSHConfigCmd.Flags().StringVar(&setSSHConfigProfile, "profile", "", "profile name to apply the SSH settings to")
|
||||
setSSHConfigCmd.Flags().StringVar(&setSSHConfigUsername, "username", "", "owning username of the profile")
|
||||
setSSHConfigCmd.Flags().BoolVar(&setSSHConfigEnableRoot, enableSSHRootFlag, false, "enable root login for the SSH server")
|
||||
setSSHConfigCmd.Flags().BoolVar(&setSSHConfigDisableAuth, disableSSHAuthFlag, false, "disable SSH authentication")
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -174,9 +172,10 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// getActiveProfileName asks the daemon for the active profile's display name,
|
||||
// annotated with the owning user when the active profile belongs to someone else.
|
||||
// Returns an empty string on any error so status output degrades gracefully.
|
||||
// getActiveProfileName asks the daemon for the active profile's display
|
||||
// name. The daemon runs as root and can read the per-user profile files to
|
||||
// resolve the ID to its human-readable name. Returns an empty string on any
|
||||
// error so status output degrades gracefully.
|
||||
func getActiveProfileName(ctx context.Context) string {
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
@@ -189,22 +188,7 @@ func getActiveProfileName(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
name := resp.GetProfileName()
|
||||
if owner := resp.GetUsername(); owner != "" {
|
||||
if curr, uerr := user.Current(); uerr != nil || !usernamesMatch(owner, curr.Username) {
|
||||
name = fmt.Sprintf("%s (user %s)", name, owner)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// usernamesMatch compares usernames case-insensitively on Windows (domain
|
||||
// accounts) and exactly elsewhere.
|
||||
func usernamesMatch(a, b string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
return a == b
|
||||
return resp.GetProfileName()
|
||||
}
|
||||
|
||||
func parseFilters() error {
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -56,7 +56,6 @@ var (
|
||||
showQR bool
|
||||
profileName string
|
||||
configPath string
|
||||
claimOwner bool
|
||||
|
||||
upCmd = &cobra.Command{
|
||||
Use: "up",
|
||||
@@ -68,7 +67,6 @@ var (
|
||||
|
||||
func init() {
|
||||
upCmd.PersistentFlags().BoolVarP(&foregroundMode, "foreground-mode", "F", false, "start service in foreground")
|
||||
upCmd.PersistentFlags().BoolVar(&claimOwner, "owner", false, "claim ownership of this profile for the current user, restricting daemon control of it to you and root/administrator")
|
||||
upCmd.PersistentFlags().StringVar(&interfaceName, interfaceNameFlag, iface.WgInterfaceDefault, "WireGuard interface name")
|
||||
upCmd.PersistentFlags().Uint16Var(&wireguardPort, wireguardPortFlag, iface.DefaultWgPort, "WireGuard interface listening port")
|
||||
upCmd.PersistentFlags().Uint16Var(&mtu, mtuFlag, iface.DefaultMTU, "Set MTU (Maximum Transmission Unit) for the WireGuard interface")
|
||||
@@ -321,31 +319,13 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
||||
return fmt.Errorf("get current user: %v", err)
|
||||
}
|
||||
|
||||
// Enabling SSH root login / disabling SSH auth is a privileged change the
|
||||
// daemon rejects from a non-privileged caller. Offer to elevate (polkit/UAC/
|
||||
// osascript) so an elevated helper applies just those settings.
|
||||
if wantsDangerousSSH(cmd) && !isProcessPrivileged() {
|
||||
cmd.Println("Enabling SSH root login / disabling SSH authentication requires administrator privileges, requesting elevation...")
|
||||
var enableRoot, disableAuth *bool
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
enableRoot = &enableSSHRoot
|
||||
}
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
disableAuth = &disableSSHAuth
|
||||
}
|
||||
if err := ElevateSSHConfig(activeProf.ID.String(), username.Username, enableRoot, disableAuth); err != nil {
|
||||
return fmt.Errorf("apply privileged SSH settings: %w", err)
|
||||
}
|
||||
sshConfigElevated = true
|
||||
}
|
||||
|
||||
// set the new config
|
||||
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
|
||||
if _, err := client.SetConfig(ctx, req); err != nil {
|
||||
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
|
||||
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
|
||||
} else {
|
||||
return fmt.Errorf("call service setConfig method: %v", err)
|
||||
return daemonCallError("call service setConfig method", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +379,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
}
|
||||
|
||||
if loginErr != nil {
|
||||
return fmt.Errorf("login failed: %v", loginErr)
|
||||
return daemonCallError("login failed", loginErr)
|
||||
}
|
||||
|
||||
if loginResp.NeedsSSOLogin {
|
||||
@@ -411,9 +391,8 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
if _, err := client.Up(ctx, &proto.UpRequest{
|
||||
ProfileName: &profileID,
|
||||
Username: &username,
|
||||
ClaimOwner: claimOwner,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("call service up method: %v", err)
|
||||
return daemonCallError("call service up method", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -442,7 +421,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
req.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
if cmd.Flag(enableSSHRootFlag).Changed && !sshConfigElevated {
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
req.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||
@@ -454,7 +433,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||
req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed && !sshConfigElevated {
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
req.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||
@@ -670,7 +649,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed && !sshConfigElevated {
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
loginRequest.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
|
||||
@@ -686,7 +665,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||
}
|
||||
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed && !sshConfigElevated {
|
||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||
loginRequest.DisableSSHAuth = &disableSSHAuth
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestUpDaemon(t *testing.T) {
|
||||
}
|
||||
|
||||
sm := profilemanager.ServiceManager{}
|
||||
created, err := sm.AddProfile("test1", currUser.Username, nil)
|
||||
created, err := sm.AddProfile("test1", currUser.Username)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add profile: %v", err)
|
||||
return
|
||||
|
||||
@@ -6,6 +6,11 @@ import (
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// 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 string
|
||||
|
||||
func init() {
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -464,6 +464,8 @@ func Test_RemovePeer(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_ConnectPeers(t *testing.T) {
|
||||
t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true")
|
||||
|
||||
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
|
||||
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
|
||||
peer1Key, _ := wgtypes.GeneratePrivateKey()
|
||||
@@ -505,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
localIP, err := getLocalIP()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
|
||||
localIP1 := "127.0.0.1"
|
||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -546,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
|
||||
localIP2 := "127.0.0.1"
|
||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -569,17 +568,17 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// todo: investigate why in some tests execution we need 30s
|
||||
// The peers use userspace WireGuard (stdnet transport). A tight busy-loop
|
||||
// here starves the wireguard-go goroutines that process the handshake, so
|
||||
// poll on a ticker instead and yield the CPU between checks. WireGuard also
|
||||
// only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which
|
||||
// is why the overall wait can occasionally stretch to tens of seconds.
|
||||
timeout := 30 * time.Second
|
||||
timeoutChannel := time.After(timeout)
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeoutChannel:
|
||||
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
|
||||
default:
|
||||
}
|
||||
|
||||
peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String())
|
||||
if gpErr != nil {
|
||||
t.Fatal(gpErr)
|
||||
@@ -588,6 +587,12 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Log("peers successfully handshake")
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-timeoutChannel:
|
||||
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -615,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) {
|
||||
}
|
||||
return wgtypes.Peer{}, fmt.Errorf("peer not found")
|
||||
}
|
||||
|
||||
func getLocalIP() (string, error) {
|
||||
// Get all interfaces
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ipNet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
|
||||
if ipNet.IP.To4() == nil {
|
||||
continue
|
||||
}
|
||||
return ipNet.IP.String(), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no local IP found")
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
||||
a.config.BlockLANAccess,
|
||||
a.config.BlockInbound,
|
||||
a.config.DisableIPv6,
|
||||
a.config.SyncMessageVersion,
|
||||
a.config.EnableSSHRoot,
|
||||
a.config.EnableSSHSFTP,
|
||||
a.config.EnableSSHLocalPortForwarding,
|
||||
|
||||
@@ -34,6 +34,8 @@ const (
|
||||
// - Handling connection establishment based on peer signaling
|
||||
//
|
||||
// The implementation is not thread-safe; it is protected by engine.syncMsgMux.
|
||||
// The only exception is ActivatePeer, which is safe for concurrent use so the
|
||||
// DNS warm-up path can call it without contending on the engine mutex.
|
||||
type ConnMgr struct {
|
||||
peerStore *peerstore.Store
|
||||
statusRecorder *peer.Status
|
||||
@@ -42,12 +44,26 @@ type ConnMgr struct {
|
||||
rosenpassEnabled bool
|
||||
|
||||
lazyConnMgr *manager.Manager
|
||||
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
|
||||
// engine loop (ActivatePeer). Writers hold it in addition to
|
||||
// engine.syncMsgMux; all other reads stay under engine.syncMsgMux only.
|
||||
lazyConnMgrMu sync.RWMutex
|
||||
|
||||
// reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is
|
||||
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
|
||||
reconcileRoutedIPs func(peerKey string) error
|
||||
|
||||
wg sync.WaitGroup
|
||||
lazyCtx context.Context
|
||||
lazyCtxCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when
|
||||
// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts.
|
||||
func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
|
||||
e.reconcileRoutedIPs = fn
|
||||
}
|
||||
|
||||
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
|
||||
e := &ConnMgr{
|
||||
peerStore: peerStore,
|
||||
@@ -238,12 +254,20 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) {
|
||||
conn.Log.Infof("removed peer from lazy conn manager")
|
||||
}
|
||||
|
||||
// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is
|
||||
// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu
|
||||
// and the manager itself is internally synchronized, so callers outside the
|
||||
// engine loop (DNS warm-up) do not need engine.syncMsgMux.
|
||||
func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) {
|
||||
if !e.isStartedWithLazyMgr() {
|
||||
e.lazyConnMgrMu.RLock()
|
||||
lazyConnMgr := e.lazyConnMgr
|
||||
started := lazyConnMgr != nil && e.lazyCtxCancel != nil
|
||||
e.lazyConnMgrMu.RUnlock()
|
||||
if !started {
|
||||
return
|
||||
}
|
||||
|
||||
if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
||||
if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found {
|
||||
if err := conn.Open(ctx); err != nil {
|
||||
conn.Log.Errorf("failed to open connection: %v", err)
|
||||
}
|
||||
@@ -268,16 +292,22 @@ func (e *ConnMgr) Close() {
|
||||
|
||||
e.lazyCtxCancel()
|
||||
e.wg.Wait()
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = nil
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
||||
cfg := manager.Config{
|
||||
InactivityThreshold: inactivityThresholdEnv(),
|
||||
ReconcileAllowedIPs: e.reconcileRoutedIPs,
|
||||
}
|
||||
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
|
||||
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
|
||||
e.wg.Add(1)
|
||||
go func() {
|
||||
@@ -316,7 +346,10 @@ func (e *ConnMgr) closeManager(ctx context.Context) {
|
||||
|
||||
e.lazyCtxCancel()
|
||||
e.wg.Wait()
|
||||
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = nil
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
|
||||
for _, peerID := range e.peerStore.PeersPubKey() {
|
||||
e.peerStore.PeerConnOpen(ctx, peerID)
|
||||
@@ -352,11 +385,20 @@ func inactivityThresholdEnv() *time.Duration {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedMinutes, err := strconv.Atoi(envValue)
|
||||
if err != nil || parsedMinutes <= 0 {
|
||||
return nil
|
||||
// Documented format: a Go duration such as "30m" or "1h".
|
||||
if d, err := time.ParseDuration(envValue); err == nil {
|
||||
if d <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &d
|
||||
}
|
||||
|
||||
d := time.Duration(parsedMinutes) * time.Minute
|
||||
return &d
|
||||
// Backwards compatibility: a bare integer used to be interpreted as minutes.
|
||||
if parsedMinutes, err := strconv.Atoi(envValue); err == nil && parsedMinutes > 0 {
|
||||
d := time.Duration(parsedMinutes) * time.Minute
|
||||
return &d
|
||||
}
|
||||
|
||||
log.Warnf("invalid %s value %q: expected a Go duration such as 30m or 1h", lazyconn.EnvInactivityThreshold, envValue)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/lazyconn"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
"github.com/netbirdio/netbird/monotime"
|
||||
)
|
||||
|
||||
func TestResolveLazyForce(t *testing.T) {
|
||||
@@ -38,3 +49,93 @@ func TestResolveLazyForce(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockLazyWGIface struct{}
|
||||
|
||||
func (mockLazyWGIface) RemovePeer(string) error { return nil }
|
||||
func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
|
||||
return nil
|
||||
}
|
||||
func (mockLazyWGIface) IsUserspaceBind() bool { return false }
|
||||
func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} }
|
||||
func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil }
|
||||
func (mockLazyWGIface) MTU() uint16 { return 1280 }
|
||||
|
||||
// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from
|
||||
// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle,
|
||||
// which stays on the engine loop. Run with -race: it fails if ActivatePeer
|
||||
// still requires engine.syncMsgMux for safety.
|
||||
func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) {
|
||||
t.Setenv(lazyconn.EnvLazyConn, "on")
|
||||
|
||||
status := peer.NewRecorder("https://mgm")
|
||||
store := peerstore.NewConnStore()
|
||||
connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{})
|
||||
|
||||
conn := newTestPeerConn(t, "peerA")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
connMgr.Start(ctx)
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for range 4 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
connMgr.ActivatePeer(ctx, conn)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Let the activators spin against the started manager, then tear it down
|
||||
// underneath them and let them spin against the stopped manager.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
connMgr.Close()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestInactivityThresholdEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val string
|
||||
want *time.Duration
|
||||
}{
|
||||
{name: "unset", val: "", want: nil},
|
||||
{name: "go duration minutes", val: "30m", want: durPtr(30 * time.Minute)},
|
||||
{name: "go duration hours", val: "1h", want: durPtr(time.Hour)},
|
||||
{name: "go duration seconds", val: "90s", want: durPtr(90 * time.Second)},
|
||||
{name: "bare integer is minutes (backwards compat)", val: "5", want: durPtr(5 * time.Minute)},
|
||||
{name: "zero duration", val: "0s", want: nil},
|
||||
{name: "zero integer", val: "0", want: nil},
|
||||
{name: "negative duration", val: "-5m", want: nil},
|
||||
{name: "garbage", val: "abc", want: nil},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(lazyconn.EnvInactivityThreshold, tc.val)
|
||||
got := inactivityThresholdEnv()
|
||||
switch {
|
||||
case tc.want == nil && got != nil:
|
||||
t.Fatalf("want nil, got %v", *got)
|
||||
case tc.want != nil && got == nil:
|
||||
t.Fatalf("want %v, got nil", *tc.want)
|
||||
case tc.want != nil && *got != *tc.want:
|
||||
t.Fatalf("want %v, got %v", *tc.want, *got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func durPtr(d time.Duration) *time.Duration { return &d }
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/internal/tunnelnotifier"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
@@ -136,10 +137,13 @@ func (c *ConnectClient) RunOniOS(
|
||||
// Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension.
|
||||
debug.SetGCPercent(5)
|
||||
|
||||
notifier := tunnelnotifier.New(networkChangeListener, dnsManager)
|
||||
defer notifier.Close()
|
||||
|
||||
mobileDependency := MobileDependency{
|
||||
FileDescriptor: fileDescriptor,
|
||||
NetworkChangeListener: networkChangeListener,
|
||||
DnsManager: dnsManager,
|
||||
NetworkChangeListener: notifier,
|
||||
DnsManager: notifier,
|
||||
StateFilePath: stateFilePath,
|
||||
TempDir: cacheDir,
|
||||
}
|
||||
@@ -621,6 +625,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
BlockLANAccess: config.BlockLANAccess,
|
||||
BlockInbound: config.BlockInbound,
|
||||
DisableIPv6: config.DisableIPv6,
|
||||
SyncMessageVersion: config.SyncMessageVersion,
|
||||
|
||||
LazyConnection: lazyconn.ParseState(config.LazyConnection),
|
||||
|
||||
@@ -696,6 +701,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
||||
config.BlockLANAccess,
|
||||
config.BlockInbound,
|
||||
config.DisableIPv6,
|
||||
config.SyncMessageVersion,
|
||||
config.EnableSSHRoot,
|
||||
config.EnableSSHSFTP,
|
||||
config.EnableSSHLocalPortForwarding,
|
||||
|
||||
15
client/internal/daemonaddr/owner.go
Normal file
15
client/internal/daemonaddr/owner.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package daemonaddr
|
||||
|
||||
// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very
|
||||
// user. That is what makes an unprivileged daemon authorize this process for the
|
||||
// changes it otherwise restricts to root or an administrator, so a client can tell
|
||||
// up front whether those controls are usable instead of letting a save fail.
|
||||
//
|
||||
// It is answered from the ownership of the socket or pipe the daemon created, so it
|
||||
// costs no round trip and needs no cooperation from the daemon. Ownership that
|
||||
// cannot be read is reported as false, including for a TCP address, so a caller
|
||||
// reading this as "the daemon would allow it" fails closed. The daemon remains the
|
||||
// only thing that authorizes anything: this only decides what a client offers.
|
||||
func DaemonRunsAsSelf(addr string) bool {
|
||||
return daemonRunsAsSelf(addr)
|
||||
}
|
||||
40
client/internal/daemonaddr/owner_unix.go
Normal file
40
client/internal/daemonaddr/owner_unix.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this
|
||||
// process's uid. Root is not treated specially here: a root caller is privileged
|
||||
// on its own merits, and a root-owned socket says nothing about the caller.
|
||||
func daemonRunsAsSelf(addr string) bool {
|
||||
path, ok := strings.CutPrefix(addr, "unix://")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Debugf("stat daemon socket %s: %v", path, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Only a socket says anything about a daemon. A directory or a leftover
|
||||
// regular file at that path is not one, and reading it as "the daemon runs as
|
||||
// us" would offer controls the daemon then refuses.
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return stat.Uid == uint32(os.Getuid())
|
||||
}
|
||||
62
client/internal/daemonaddr/owner_unix_test.go
Normal file
62
client/internal/daemonaddr/owner_unix_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A socket this user created means the daemon runs as this user, which is the
|
||||
// rootless case where the daemon delegates its authority to its own identity.
|
||||
func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "netbird.sock")
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := ln.Close(); err != nil {
|
||||
t.Logf("close listener: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if !DaemonRunsAsSelf("unix://" + path) {
|
||||
t.Error("a socket owned by this user must count as the daemon running as us")
|
||||
}
|
||||
}
|
||||
|
||||
// Everything that is not a readable socket of ours has to answer false, because
|
||||
// the caller reads a true as "the daemon would authorize me".
|
||||
func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// A socket owned by another user, which is what a root-run daemon looks like
|
||||
// to an unprivileged client. Only assertable when we are not root ourselves.
|
||||
rootOwned := "unix:///var/run/netbird.sock"
|
||||
if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 {
|
||||
if DaemonRunsAsSelf(rootOwned) {
|
||||
t.Error("a socket owned by another user must not count as ours")
|
||||
}
|
||||
}
|
||||
|
||||
for name, addr := range map[string]string{
|
||||
"missing socket": "unix://" + filepath.Join(dir, "absent.sock"),
|
||||
"tcp address": "tcp://127.0.0.1:41731",
|
||||
"named pipe": "npipe://netbird",
|
||||
"empty": "",
|
||||
"no scheme": filepath.Join(dir, "absent.sock"),
|
||||
"directory": "unix://" + dir,
|
||||
"unknown scheme": "http://localhost:8080",
|
||||
"scheme only": "unix://",
|
||||
"relative socket": "unix://netbird.sock",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if DaemonRunsAsSelf(addr) {
|
||||
t.Errorf("%q must not count as a daemon running as us", addr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
42
client/internal/daemonaddr/owner_windows.go
Normal file
42
client/internal/daemonaddr/owner_windows.go
Normal file
@@ -0,0 +1,42 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the
|
||||
// service account owns its pipe as LocalSystem, and an elevated one as
|
||||
// BUILTIN\Administrators, so only a daemon the user started themselves matches.
|
||||
func daemonRunsAsSelf(addr string) bool {
|
||||
name, ok := strings.CutPrefix(addr, pipeScheme)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, path := range PipePaths(name) {
|
||||
// Bounded: this runs on the UI's path for deciding which controls to
|
||||
// offer, so a pipe that does not answer promptly must not stall it. A
|
||||
// timeout leaves the caller unprivileged, which only disables controls.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
|
||||
conn, err := dialPipe(ctx, path)
|
||||
cancel()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
owned := ipcauth.PipeOwnedBySelf(conn)
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr)
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
103
client/internal/daemonaddr/pipe.go
Normal file
103
client/internal/daemonaddr/pipe.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
// WindowsPipeAddr is the default daemon address on Windows. A named pipe
|
||||
// carries the connecting process's token, which loopback TCP does not, so
|
||||
// it is the only Windows transport on which the daemon can tell who is
|
||||
// calling it.
|
||||
WindowsPipeAddr = "npipe://netbird"
|
||||
|
||||
// legacyWindowsAddr is the loopback-TCP address the Windows daemon used
|
||||
// before named-pipe support.
|
||||
legacyWindowsAddr = "tcp://127.0.0.1:41731"
|
||||
|
||||
pipeScheme = "npipe://"
|
||||
|
||||
// protectedPrefix is the NPFS namespace in which only LocalSystem and
|
||||
// members of BUILTIN\Administrators may create a pipe. A daemon running as
|
||||
// the service account creates its pipe there so that an unprivileged process
|
||||
// cannot pre-create the name, which would keep the daemon from starting and
|
||||
// leave callers talking to the squatter. Opening such a pipe needs no
|
||||
// privilege, so unprivileged clients still reach the daemon.
|
||||
protectedPrefix = `ProtectedPrefix\Administrators\`
|
||||
)
|
||||
|
||||
// DialTarget returns the gRPC dial target and transport options for a daemon
|
||||
// address. The npipe scheme needs a context dialer because gRPC has no
|
||||
// named-pipe resolver; unix and tcp are handled by gRPC itself.
|
||||
func DialTarget(addr string) (string, []grpc.DialOption) {
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
|
||||
if name, ok := strings.CutPrefix(addr, pipeScheme); ok {
|
||||
paths := PipePaths(name)
|
||||
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return dialPipePaths(ctx, paths)
|
||||
}))
|
||||
return "passthrough:///netbird-daemon-pipe", opts
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(addr, "tcp://"), opts
|
||||
}
|
||||
|
||||
// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a
|
||||
// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as
|
||||
// is.
|
||||
func PipePath(name string) string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return name
|
||||
}
|
||||
return `\\.\pipe\` + name
|
||||
}
|
||||
|
||||
// PipePaths returns the paths a daemon control pipe may live at for an npipe
|
||||
// address name, in the order both sides must try them: the protected name first,
|
||||
// then the plain one.
|
||||
//
|
||||
// The daemon serves the first it can create, which is the protected name when it
|
||||
// runs as the service account and the plain one when it runs as an ordinary user,
|
||||
// as it does in netstack mode. Clients therefore have to try both, and because a
|
||||
// client cannot tell from the name alone who created the pipe, the plain name is
|
||||
// only usable once the server's identity has been checked: see
|
||||
// verifyPipeServer.
|
||||
//
|
||||
// A fully qualified path is what the operator asked for and is used as is.
|
||||
func PipePaths(name string) []string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return []string{name}
|
||||
}
|
||||
return []string{PipePath(protectedPrefix + name), PipePath(name)}
|
||||
}
|
||||
|
||||
// IsProtectedPipePath reports whether a pipe path is in the namespace only an
|
||||
// administrator or LocalSystem can create in, which is what lets a client trust
|
||||
// such a pipe from its name alone.
|
||||
func IsProtectedPipePath(path string) bool {
|
||||
return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix)
|
||||
}
|
||||
|
||||
// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named
|
||||
// pipe, reporting whether it rewrote the address. Existing installs persist the
|
||||
// daemon address, so without this an upgraded daemon would keep listening on
|
||||
// loopback TCP, where callers carry no identity and privileged operations would
|
||||
// have to be refused for everyone. Only the exact legacy default is rewritten:
|
||||
// a deliberately chosen custom address is left alone.
|
||||
func MigrateLegacy(addr string) (string, bool) {
|
||||
return migrateLegacyForOS(runtime.GOOS, addr)
|
||||
}
|
||||
|
||||
func migrateLegacyForOS(goos, addr string) (string, bool) {
|
||||
if goos == "windows" && addr == legacyWindowsAddr {
|
||||
return WindowsPipeAddr, true
|
||||
}
|
||||
return addr, false
|
||||
}
|
||||
15
client/internal/daemonaddr/pipe_other.go
Normal file
15
client/internal/daemonaddr/pipe_other.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
// dialPipePaths is Windows-only: no other platform serves the daemon on a named
|
||||
// pipe.
|
||||
func dialPipePaths(context.Context, []string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("named pipes are only supported on Windows")
|
||||
}
|
||||
30
client/internal/daemonaddr/pipe_test.go
Normal file
30
client/internal/daemonaddr/pipe_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The protected name must be tried before the plain one on both sides: it is the
|
||||
// one an unprivileged process cannot create, so preferring it is what keeps a
|
||||
// squatter from owning the name the service daemon would otherwise use.
|
||||
func TestPipePaths_PrefersTheProtectedName(t *testing.T) {
|
||||
got := PipePaths("netbird")
|
||||
want := []string{
|
||||
`\\.\pipe\ProtectedPrefix\Administrators\netbird`,
|
||||
`\\.\pipe\netbird`,
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("PipePaths = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator who passes a full path chose exactly one pipe, so neither side may
|
||||
// look anywhere else.
|
||||
func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) {
|
||||
path := `\\.\pipe\custom-netbird`
|
||||
got := PipePaths(path)
|
||||
if !slices.Equal(got, []string{path}) {
|
||||
t.Errorf("PipePaths = %q, want just %q", got, path)
|
||||
}
|
||||
}
|
||||
59
client/internal/daemonaddr/pipe_windows.go
Normal file
59
client/internal/daemonaddr/pipe_windows.go
Normal file
@@ -0,0 +1,59 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// dialPipePaths connects to the first path that answers with a pipe server this
|
||||
// client may trust, and returns the last error when none does.
|
||||
func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) {
|
||||
var lastErr error
|
||||
for _, path := range paths {
|
||||
conn, err := dialPipe(ctx, path)
|
||||
if err != nil {
|
||||
log.Debugf("dial daemon pipe %s: %v", path, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// A pipe in the protected namespace could only have been created by an
|
||||
// administrator or LocalSystem, so its name is the guarantee. Any other
|
||||
// name has to be checked, because any local user can create one.
|
||||
if !IsProtectedPipePath(path) {
|
||||
if err := ipcauth.PipeServerTrusted(conn); err != nil {
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
log.Debugf("close untrusted pipe %s: %v", path, closeErr)
|
||||
}
|
||||
lastErr = fmt.Errorf("%s: %w", path, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no daemon pipe to connect to")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION.
|
||||
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon
|
||||
// cannot read the caller's token at all. Identification lets the daemon read the
|
||||
// caller's SID and groups without granting it the ability to act as the caller.
|
||||
func dialPipe(ctx context.Context, path string) (net.Conn, error) {
|
||||
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
|
||||
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
|
||||
}
|
||||
9
client/internal/daemonaddr/resolve_pipe_other.go
Normal file
9
client/internal/daemonaddr/resolve_pipe_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe
|
||||
// default to fall back from.
|
||||
func ResolveDaemonAddr(addr string) string {
|
||||
return addr
|
||||
}
|
||||
82
client/internal/daemonaddr/resolve_pipe_windows.go
Normal file
82
client/internal/daemonaddr/resolve_pipe_windows.go
Normal file
@@ -0,0 +1,82 @@
|
||||
//go:build windows
|
||||
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// probeTimeout bounds each transport probe. Both are local, so a daemon that is
|
||||
// listening answers immediately and one that is not fails immediately.
|
||||
const probeTimeout = 300 * time.Millisecond
|
||||
|
||||
// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it
|
||||
// off. When the pipe does not answer it checks the legacy loopback TCP address, so
|
||||
// a client meeting a daemon that has not restarted since the upgrade can say what
|
||||
// is wrong, but it does not connect there.
|
||||
//
|
||||
// Using that address automatically would be a downgrade the user never asked for:
|
||||
// any local process can bind 127.0.0.1 while the daemon is not listening, and the
|
||||
// transport carries no caller identity, so a client that accepted whatever answered
|
||||
// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An
|
||||
// operator who needs the legacy address during the upgrade window can still pass
|
||||
// --daemon-addr explicitly, which is a deliberate choice and still refuses the
|
||||
// privileged operations.
|
||||
//
|
||||
// Only the pipe address is resolved. A custom address is left alone, though passing
|
||||
// --daemon-addr npipe://netbird explicitly is indistinguishable from the default
|
||||
// here, so it is treated the same way.
|
||||
func ResolveDaemonAddr(addr string) string {
|
||||
if addr != WindowsPipeAddr {
|
||||
return addr
|
||||
}
|
||||
|
||||
for _, path := range PipePaths("netbird") {
|
||||
if pipeAvailable(path) {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
|
||||
if tcpAvailable(legacyWindowsAddr) {
|
||||
log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+
|
||||
"Restart the NetBird service so it serves the pipe. That address is not used automatically: "+
|
||||
"any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+
|
||||
"explicitly if you accept that",
|
||||
WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr)
|
||||
}
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
func pipeAvailable(path string) bool {
|
||||
timeout := probeTimeout
|
||||
conn, err := winio.DialPipe(path, &timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("close daemon pipe probe: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func tcpAvailable(addr string) bool {
|
||||
host := addr
|
||||
if _, after, ok := strings.Cut(addr, "://"); ok {
|
||||
host = after
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", host, probeTimeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("close daemon TCP probe: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -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,
|
||||
@@ -676,6 +702,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
|
||||
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
|
||||
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
|
||||
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
|
||||
|
||||
if g.internalConfig.DisableNotifications != nil {
|
||||
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))
|
||||
@@ -995,11 +1022,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)
|
||||
@@ -1008,11 +1035,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)
|
||||
}
|
||||
|
||||
@@ -1029,18 +1056,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)
|
||||
}
|
||||
@@ -1065,8 +1092,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)
|
||||
}
|
||||
@@ -1113,7 +1140,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
|
||||
}
|
||||
@@ -1153,9 +1180,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()))
|
||||
|
||||
@@ -887,6 +887,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
ClientCertKeyPath: "/tmp/key",
|
||||
LazyConnection: "on",
|
||||
MTU: 1280,
|
||||
DisableIPv6: true,
|
||||
SyncMessageVersion: func(v int) *int { return &v }(1),
|
||||
}
|
||||
|
||||
for _, anonymize := range []bool{false, true} {
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -36,7 +37,43 @@ type resolver interface {
|
||||
// record is left alone (it points at something outside our mesh, e.g.
|
||||
// a non-peer upstream).
|
||||
type PeerConnectivity interface {
|
||||
IsConnectedByIP(ip string) (known, connected bool)
|
||||
IsConnectedByIP(ip netip.Addr) (known, connected bool)
|
||||
}
|
||||
|
||||
// PeerActivator wakes lazy-connection peers on demand. The local resolver calls
|
||||
// it with the tunnel IPs an answer points at, so a peer that is idle (lazily
|
||||
// disconnected) starts connecting at DNS-resolution time rather than racing the
|
||||
// client's first request packet. nil disables warm-up.
|
||||
type PeerActivator interface {
|
||||
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks
|
||||
// until one is connected or ctx (a short per-query budget) expires. It is a
|
||||
// fast no-op for unknown or already-connected addresses.
|
||||
ActivatePeersByIP(ctx context.Context, addrs []netip.Addr)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultLazyWarmupTimeout = 2 * time.Second
|
||||
envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT"
|
||||
)
|
||||
|
||||
// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a
|
||||
// lazy-connection peer a DNS answer points at. Tunable via
|
||||
// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time.
|
||||
func lazyWarmupTimeoutFromEnv() time.Duration {
|
||||
v := os.Getenv(envLazyWarmupTimeout)
|
||||
if v == "" {
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err)
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
if d <= 0 {
|
||||
log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout)
|
||||
return defaultLazyWarmupTimeout
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
@@ -51,6 +88,12 @@ type Resolver struct {
|
||||
// filter and preserves the legacy "return whatever is registered"
|
||||
// behaviour for callers that never wire a status source.
|
||||
peerConn PeerConnectivity
|
||||
// peerActivator, when non-nil, is called at resolution time to warm the
|
||||
// lazy connection to the peer(s) an answer points at. nil disables warm-up.
|
||||
peerActivator PeerActivator
|
||||
// warmupTimeout is the per-query budget for the lazy-connection warm-up
|
||||
// wait, resolved from the environment once at construction time.
|
||||
warmupTimeout time.Duration
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -59,11 +102,12 @@ type Resolver struct {
|
||||
func NewResolver() *Resolver {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Resolver{
|
||||
records: make(map[dns.Question][]dns.RR),
|
||||
domains: make(map[domain.Domain]struct{}),
|
||||
zones: make(map[domain.Domain]bool),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
records: make(map[dns.Question][]dns.RR),
|
||||
domains: make(map[domain.Domain]struct{}),
|
||||
zones: make(map[domain.Domain]bool),
|
||||
warmupTimeout: lazyWarmupTimeoutFromEnv(),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +120,14 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
|
||||
d.peerConn = p
|
||||
}
|
||||
|
||||
// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to
|
||||
// disable. Safe to call multiple times; the latest value wins.
|
||||
func (d *Resolver) SetPeerActivator(a PeerActivator) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.peerActivator = a
|
||||
}
|
||||
|
||||
func (d *Resolver) MatchSubdomains() bool {
|
||||
return true
|
||||
}
|
||||
@@ -122,6 +174,9 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
replyMessage.RecursionAvailable = true
|
||||
|
||||
result := d.lookupRecords(logger, question)
|
||||
// Warm before filtering: activation flips a lazily-idle target to connected,
|
||||
// which then lets it survive the disconnected-peer filter below.
|
||||
d.warmLazyPeers(question, result.records)
|
||||
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
|
||||
replyMessage.Authoritative = !result.hasExternalData
|
||||
replyMessage.Answer = result.records
|
||||
@@ -495,8 +550,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
||||
kept := make([]dns.RR, 0, len(records))
|
||||
var dropped int
|
||||
for _, rr := range records {
|
||||
ip := extractRecordIP(rr)
|
||||
if ip == "" {
|
||||
ip, ok := extractRecordAddr(rr)
|
||||
if !ok {
|
||||
kept = append(kept, rr)
|
||||
continue
|
||||
}
|
||||
@@ -518,22 +573,57 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
|
||||
return kept
|
||||
}
|
||||
|
||||
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
|
||||
// an A or AAAA record, or "" for any other record type.
|
||||
func extractRecordIP(rr dns.RR) string {
|
||||
// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved
|
||||
// answer points at and waits briefly for one to connect, so the caller's first
|
||||
// request doesn't race the connection establishment. Warm-up is scoped to
|
||||
// match-only (non-authoritative) zones — the synthesized private-service zones
|
||||
// and user-created zones whose records point at specific peers. The account's
|
||||
// peer zone is authoritative, so plain peer-name lookups never trigger warm-up;
|
||||
// otherwise resolving any peer's name would wake its idle connection, defeating
|
||||
// laziness mesh-wide. No-op when no activator is wired (lazy connections
|
||||
// disabled) or the answer carries no peer IPs.
|
||||
func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) {
|
||||
if len(records) < 2 {
|
||||
return
|
||||
}
|
||||
d.mu.RLock()
|
||||
activator := d.peerActivator
|
||||
var nonAuth, found bool
|
||||
if activator != nil {
|
||||
nonAuth, found = d.findZone(question.Name)
|
||||
}
|
||||
d.mu.RUnlock()
|
||||
if activator == nil || !found || !nonAuth {
|
||||
return
|
||||
}
|
||||
|
||||
var addrs []netip.Addr
|
||||
for _, rr := range records {
|
||||
if addr, ok := extractRecordAddr(rr); ok {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout)
|
||||
defer cancel()
|
||||
activator.ActivatePeersByIP(ctx, addrs)
|
||||
}
|
||||
|
||||
// extractRecordAddr returns the IP address carried by an A or AAAA record.
|
||||
// ok is false for any other record type or a record with no address.
|
||||
func extractRecordAddr(rr dns.RR) (netip.Addr, bool) {
|
||||
switch r := rr.(type) {
|
||||
case *dns.A:
|
||||
if r.A == nil {
|
||||
return ""
|
||||
}
|
||||
return r.A.String()
|
||||
addr, ok := netip.AddrFromSlice(r.A)
|
||||
return addr.Unmap(), ok
|
||||
case *dns.AAAA:
|
||||
if r.AAAA == nil {
|
||||
return ""
|
||||
}
|
||||
return r.AAAA.String()
|
||||
addr, ok := netip.AddrFromSlice(r.AAAA)
|
||||
return addr.Unmap(), ok
|
||||
}
|
||||
return ""
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// Update replaces all zones and their records
|
||||
|
||||
@@ -37,8 +37,8 @@ type mockPeerConnectivity struct {
|
||||
byIP map[string]struct{ known, connected bool }
|
||||
}
|
||||
|
||||
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||
v, ok := m.byIP[ip]
|
||||
func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
|
||||
v, ok := m.byIP[ip.String()]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
204
client/internal/dns/local/warmup_test.go
Normal file
204
client/internal/dns/local/warmup_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/dns/test"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
)
|
||||
|
||||
// recordingActivator records the addresses it was asked to warm and returns
|
||||
// immediately, so ServeDNS is not blocked by the test.
|
||||
type recordingActivator struct {
|
||||
mu sync.Mutex
|
||||
called bool
|
||||
addrs []netip.Addr
|
||||
}
|
||||
|
||||
func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.called = true
|
||||
r.addrs = append(r.addrs, addrs...)
|
||||
}
|
||||
|
||||
func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg {
|
||||
t.Helper()
|
||||
var resp *dns.Msg
|
||||
w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}
|
||||
resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA))
|
||||
return resp
|
||||
}
|
||||
|
||||
// serviceZone registers rec in a match-only (non-authoritative) zone, the shape
|
||||
// the synthesized private-service zones arrive in.
|
||||
func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) {
|
||||
t.Helper()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: zone,
|
||||
Records: records,
|
||||
NonAuthoritative: true,
|
||||
}})
|
||||
}
|
||||
|
||||
func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) {
|
||||
// Warm-up fires only for multi-record answers (the HA / round-robin shape of
|
||||
// the synthesized private-service zones), so register two peer targets.
|
||||
const name = "svc.proxy.netbird.cloud."
|
||||
recs := []nbdns.SimpleRecord{
|
||||
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"},
|
||||
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"},
|
||||
}
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud", recs...)
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
resp := serveA(t, resolver, name)
|
||||
require.NotNil(t, resp, "resolver must answer")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer")
|
||||
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP")
|
||||
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) {
|
||||
// A single-record answer does not trigger warm-up; the resolver only warms
|
||||
// multi-record answers.
|
||||
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
resp := serveA(t, resolver, rec.Name)
|
||||
require.NotNil(t, resp, "resolver must answer")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.False(t, act.called, "activator must not be invoked for a single-record answer")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) {
|
||||
// With no activator wired the resolver behaves exactly as before.
|
||||
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
|
||||
|
||||
resp := serveA(t, resolver, rec.Name)
|
||||
require.NotNil(t, resp, "resolver must still answer without an activator")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) {
|
||||
// A query that resolves to nothing must not invoke the activator (no IPs).
|
||||
resolver := NewResolver()
|
||||
serviceZone(t, resolver, "proxy.netbird.cloud",
|
||||
nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"})
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
serveA(t, resolver, "absent.proxy.netbird.cloud.")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.False(t, act.called, "activator must not be invoked when there is no answer")
|
||||
}
|
||||
|
||||
func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) {
|
||||
// The account's peer zone is authoritative; resolving a peer's name there
|
||||
// must not wake its lazy connection — warm-up is scoped to match-only
|
||||
// (non-authoritative) zones such as the synthesized private-service zones.
|
||||
// Use a multi-record answer so the authoritative-zone scoping is the only
|
||||
// reason warm-up is skipped, not the single-record guard.
|
||||
const name = "peer.netbird.cloud."
|
||||
recs := []nbdns.SimpleRecord{
|
||||
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"},
|
||||
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"},
|
||||
}
|
||||
resolver := NewResolver()
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: "netbird.cloud",
|
||||
Records: recs,
|
||||
}})
|
||||
|
||||
act := &recordingActivator{}
|
||||
resolver.SetPeerActivator(act)
|
||||
|
||||
resp := serveA(t, resolver, name)
|
||||
require.NotNil(t, resp, "resolver must answer")
|
||||
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
|
||||
|
||||
act.mu.Lock()
|
||||
defer act.mu.Unlock()
|
||||
assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers")
|
||||
}
|
||||
|
||||
func TestLazyWarmupTimeoutFromEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
envSet bool
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "unset uses default", want: defaultLazyWarmupTimeout},
|
||||
{name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second},
|
||||
{name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
{name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
{name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envSet {
|
||||
t.Setenv(envLazyWarmupTimeout, tt.value)
|
||||
}
|
||||
assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv())
|
||||
assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRecordAddr(t *testing.T) {
|
||||
t.Run("A record yields unmapped v4", func(t *testing.T) {
|
||||
// net.ParseIP returns the 16-byte v4-in-v6 form, the same shape
|
||||
// miekg/dns stores after parsing an A record; the extracted address
|
||||
// must compare equal to a plain v4 netip.Addr.
|
||||
addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")})
|
||||
require.True(t, ok)
|
||||
assert.True(t, addr.Is4())
|
||||
assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr)
|
||||
})
|
||||
|
||||
t.Run("AAAA record yields v6", func(t *testing.T) {
|
||||
addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, netip.MustParseAddr("fd00::1"), addr)
|
||||
})
|
||||
|
||||
t.Run("A record without address", func(t *testing.T) {
|
||||
_, ok := extractRecordAddr(&dns.A{})
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("non-address record", func(t *testing.T) {
|
||||
_, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."})
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/local"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -92,6 +93,11 @@ func (m *MockServer) SetFirewall(Firewall) {
|
||||
// Mock implementation - no-op
|
||||
}
|
||||
|
||||
// SetPeerActivator mock implementation of SetPeerActivator from Server interface
|
||||
func (m *MockServer) SetPeerActivator(local.PeerActivator) {
|
||||
// Mock implementation - no-op
|
||||
}
|
||||
|
||||
// BeginBatch mock implementation of BeginBatch from Server interface
|
||||
func (m *MockServer) BeginBatch() {
|
||||
// Mock implementation - no-op
|
||||
|
||||
@@ -82,6 +82,7 @@ type Server interface {
|
||||
PopulateManagementDomain(mgmtURL *url.URL) error
|
||||
SetRouteSources(selected, active func() route.HAMap)
|
||||
SetFirewall(Firewall)
|
||||
SetPeerActivator(local.PeerActivator)
|
||||
}
|
||||
|
||||
type nsGroupsByDomain struct {
|
||||
@@ -491,6 +492,13 @@ func (s *DefaultServer) SetFirewall(fw Firewall) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local
|
||||
// resolver. Injected after the connection manager exists (it does not at
|
||||
// DNS-server construction time). Pass nil to disable.
|
||||
func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) {
|
||||
s.localResolver.SetPeerActivator(a)
|
||||
}
|
||||
|
||||
// Stop stops the server
|
||||
func (s *DefaultServer) Stop() {
|
||||
s.ctxCancel()
|
||||
@@ -1435,11 +1443,11 @@ type localPeerConnectivity struct {
|
||||
|
||||
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
|
||||
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
|
||||
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||
func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
|
||||
if l.status == nil {
|
||||
return false, false
|
||||
}
|
||||
state, ok := l.status.PeerStateByIP(ip)
|
||||
state, ok := l.status.PeerStateByIP(ip.String())
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
76
client/internal/dns_peer_activator.go
Normal file
76
client/internal/dns_peer_activator.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
)
|
||||
|
||||
const dnsActivationPollInterval = 50 * time.Millisecond
|
||||
|
||||
// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It
|
||||
// implements dns/local.PeerActivator. DNS queries run on their own goroutines,
|
||||
// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer,
|
||||
// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux,
|
||||
// keeping DNS resolution from contending with network-map processing.
|
||||
type dnsPeerActivator struct {
|
||||
connMgr *ConnMgr
|
||||
peerStore *peerstore.Store
|
||||
status *peer.Status
|
||||
// ctx is the engine's long-lived context. The connection dial is tied to it
|
||||
// (not the per-query DNS wait budget) so a handshake that outlasts the wait
|
||||
// still completes in the background rather than being cancelled at the deadline.
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits
|
||||
// until one is connected or ctx (the per-query DNS wait budget) expires.
|
||||
// Activation itself is tied to the engine's long-lived context so the dial
|
||||
// survives a wait that times out. Unknown or already-connected addresses are
|
||||
// skipped, so the steady-state (warm) path adds no latency.
|
||||
func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) {
|
||||
if a == nil || a.connMgr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var pending []string
|
||||
for _, addr := range addrs {
|
||||
ip := addr.String()
|
||||
st, ok := a.status.PeerStateByIP(ip)
|
||||
if !ok || st.ConnStatus == peer.StatusConnected {
|
||||
continue
|
||||
}
|
||||
conn, ok := a.peerStore.PeerConn(st.PubKey)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
a.connMgr.ActivatePeer(a.ctx, conn)
|
||||
pending = append(pending, ip)
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
a.waitConnected(ctx, pending)
|
||||
}
|
||||
|
||||
// waitConnected blocks until any of ips reports a connected peer or ctx expires.
|
||||
func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) {
|
||||
ticker := time.NewTicker(dnsActivationPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
for _, ip := range ips {
|
||||
if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected {
|
||||
return
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
129
client/internal/dns_peer_activator_test.go
Normal file
129
client/internal/dns_peer_activator_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
)
|
||||
|
||||
func newTestPeerConn(t *testing.T, key string) *peer.Conn {
|
||||
t.Helper()
|
||||
conn, err := peer.NewConn(peer.ConnConfig{
|
||||
Key: key,
|
||||
LocalKey: "local",
|
||||
WgConfig: peer.WgConfig{
|
||||
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
}, peer.ServiceDependencies{})
|
||||
require.NoError(t, err)
|
||||
return conn
|
||||
}
|
||||
|
||||
func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) {
|
||||
t.Helper()
|
||||
status := peer.NewRecorder("https://mgm")
|
||||
store := peerstore.NewConnStore()
|
||||
// ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a
|
||||
// no-op — these tests exercise the activator's skip/wait logic.
|
||||
connMgr := NewConnMgr(&EngineConfig{}, status, store, nil)
|
||||
return &dnsPeerActivator{
|
||||
connMgr: connMgr,
|
||||
peerStore: store,
|
||||
status: status,
|
||||
ctx: context.Background(),
|
||||
}, status, store
|
||||
}
|
||||
|
||||
func TestDNSPeerActivator_NilSafe(t *testing.T) {
|
||||
var a *dnsPeerActivator
|
||||
a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state
|
||||
// (warm) path adds no latency: already-connected and unknown addresses never
|
||||
// enter the wait loop.
|
||||
func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1"))
|
||||
require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{
|
||||
netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped
|
||||
netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped
|
||||
netip.MustParseAddr("100.64.0.99"), // unknown -> skipped
|
||||
})
|
||||
require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop
|
||||
// returns as soon as a pending peer reports connected, well before the
|
||||
// per-query budget expires.
|
||||
func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer")
|
||||
require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that
|
||||
// never connects releases the DNS response at the per-query budget instead of
|
||||
// blocking it indefinitely.
|
||||
func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) {
|
||||
a, status, store := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer")
|
||||
require.Less(t, elapsed, 5*time.Second, "must not block past the budget")
|
||||
}
|
||||
|
||||
// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer
|
||||
// with no connection object in the store is not waited on: there is nothing to
|
||||
// activate, so waiting could only ever time out.
|
||||
func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) {
|
||||
a, status, _ := newTestDNSPeerActivator(t)
|
||||
|
||||
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
|
||||
require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on")
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -52,11 +52,14 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
|
||||
|
||||
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
|
||||
udp->dest = dns_port;
|
||||
// Clear the now-stale checksum; zero means "not computed" for IPv4.
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
if (udp->source == dns_port && ip->saddr == dns_ip) {
|
||||
udp->source = GENERAL_DNS_PORT;
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
|
||||
__be16 new_dst_port = htons(proxy_port);
|
||||
udp->dest = new_dst_port;
|
||||
udp->source = new_src_port;
|
||||
|
||||
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
|
||||
// and the payload is already integrity-protected, so clear the checksum (a
|
||||
// zero UDP checksum means "not computed" for IPv4) rather than leave a
|
||||
// stale value the kernel would drop as UDP_CSUM.
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,10 @@ import (
|
||||
"github.com/netbirdio/netbird/route"
|
||||
mgm "github.com/netbirdio/netbird/shared/management/client"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
|
||||
nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
types "github.com/netbirdio/netbird/shared/management/types"
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
@@ -147,6 +150,7 @@ type EngineConfig struct {
|
||||
BlockLANAccess bool
|
||||
BlockInbound bool
|
||||
DisableIPv6 bool
|
||||
SyncMessageVersion *int
|
||||
|
||||
// LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to
|
||||
// the env var and management feature flag.
|
||||
@@ -220,6 +224,13 @@ type Engine struct {
|
||||
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
|
||||
networkSerial uint64
|
||||
|
||||
// latestComponents is the most-recent NetworkMapComponents decoded from
|
||||
// a NetworkMapEnvelope (capability=3 peers only). Held alongside the
|
||||
// NetworkMap that Calculate() produced from it so future incremental
|
||||
// updates have a base to apply changes against. nil for legacy-format
|
||||
// peers. Guarded by syncMsgMux.
|
||||
latestComponents *types.NetworkMapComponents
|
||||
|
||||
networkMonitor *networkmonitor.NetworkMonitor
|
||||
|
||||
sshServer sshServer
|
||||
@@ -652,8 +663,24 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
iceCfg := e.createICEConfig()
|
||||
|
||||
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
|
||||
e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error {
|
||||
if e.routeManager == nil {
|
||||
return nil
|
||||
}
|
||||
return e.routeManager.ReconcilePeerAllowedIPs(peerKey)
|
||||
})
|
||||
e.connMgr.Start(e.ctx)
|
||||
|
||||
// Wire DNS-time lazy-connection warm-up now that the connection manager
|
||||
// exists (it does not at DNS-server construction time). A DNS answer that
|
||||
// points at an idle peer then wakes it before the client's first request.
|
||||
e.dnsServer.SetPeerActivator(&dnsPeerActivator{
|
||||
connMgr: e.connMgr,
|
||||
peerStore: e.peerStore,
|
||||
status: e.statusRecorder,
|
||||
ctx: e.ctx,
|
||||
})
|
||||
|
||||
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
|
||||
e.srWatcher.Start(peer.IsForceRelayed())
|
||||
|
||||
@@ -963,8 +990,12 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
|
||||
e.ApplySessionDeadline(update.GetSessionExpiresAt())
|
||||
|
||||
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
|
||||
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
|
||||
// Envelope sync responses carry PeerConfig at the top level; legacy
|
||||
// NetworkMap syncs carry it under NetworkMap.PeerConfig.
|
||||
if pc := update.GetPeerConfig(); pc != nil {
|
||||
e.handleAutoUpdateVersion(pc.GetAutoUpdate())
|
||||
} else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil {
|
||||
e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate())
|
||||
}
|
||||
|
||||
done := e.phase("netbird_config")
|
||||
@@ -974,12 +1005,47 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Decode the network map from either the components envelope or the
|
||||
// legacy proto.NetworkMap before the posture-check gating below, so the
|
||||
// "is there a network map" decision covers both wire shapes.
|
||||
var (
|
||||
nm *mgmProto.NetworkMap
|
||||
components *types.NetworkMapComponents
|
||||
)
|
||||
if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) {
|
||||
// Components-format peer: decode the envelope back to typed
|
||||
// components, run Calculate() locally, and convert to the wire
|
||||
// NetworkMap shape the rest of the engine consumes. Components are
|
||||
// retained so future incremental updates can apply deltas instead
|
||||
// of doing a full reconstruction.
|
||||
envelope := update.GetNetworkMapEnvelope()
|
||||
if envelope == nil {
|
||||
return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing")
|
||||
}
|
||||
|
||||
localKey := e.config.WgPrivateKey.PublicKey().String()
|
||||
dnsName := ""
|
||||
if pc := update.GetPeerConfig(); pc != nil {
|
||||
// PeerConfig.Fqdn = "<dns_label>.<dns_domain>" — extract the
|
||||
// shared domain by stripping the peer's own label prefix. Falls
|
||||
// back to empty if the FQDN doesn't have the expected shape.
|
||||
dnsName = extractDNSDomainFromFQDN(pc.GetFqdn())
|
||||
}
|
||||
result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode network map envelope: %w", err)
|
||||
}
|
||||
nm = result.NetworkMap
|
||||
components = result.Components
|
||||
} else {
|
||||
nm = update.GetNetworkMap()
|
||||
}
|
||||
|
||||
// Posture checks are bound to the network map presence:
|
||||
// NetworkMap != nil, checks present -> apply the received checks
|
||||
// NetworkMap != nil, checks nil -> posture checks were removed, clear them
|
||||
// NetworkMap == nil -> config-only update (e.g. relay token rotation),
|
||||
// leave the previously applied checks untouched
|
||||
nm := update.GetNetworkMap()
|
||||
if nm == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -992,6 +1058,14 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
}
|
||||
|
||||
done = e.phase("persist")
|
||||
// Only retain the components view when the server sent the envelope
|
||||
// path. A legacy proto.NetworkMap means components == nil; writing it
|
||||
// here would clobber a previously-cached snapshot, breaking the
|
||||
// incremental-delta base on a future envelope sync.
|
||||
if components != nil {
|
||||
e.latestComponents = components
|
||||
}
|
||||
|
||||
e.persistSyncResponse(update)
|
||||
done()
|
||||
|
||||
@@ -1005,6 +1079,19 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractDNSDomainFromFQDN returns the trailing dotted domain part of the
|
||||
// receiving peer's FQDN — the same value the management server fills as
|
||||
// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" →
|
||||
// "netbird.cloud". An empty string is returned for unrecognized formats.
|
||||
func extractDNSDomainFromFQDN(fqdn string) string {
|
||||
for i := 0; i < len(fqdn); i++ {
|
||||
if fqdn[i] == '.' && i+1 < len(fqdn) {
|
||||
return fqdn[i+1:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// updateNetbirdConfig applies the management-provided NetBird configuration:
|
||||
// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
|
||||
// which is the case for sync updates carrying only a network map.
|
||||
@@ -1164,6 +1251,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
||||
e.config.BlockLANAccess,
|
||||
e.config.BlockInbound,
|
||||
e.config.DisableIPv6,
|
||||
e.config.SyncMessageVersion,
|
||||
e.config.EnableSSHRoot,
|
||||
e.config.EnableSSHSFTP,
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
@@ -2032,6 +2120,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
||||
e.config.BlockLANAccess,
|
||||
e.config.BlockInbound,
|
||||
e.config.DisableIPv6,
|
||||
e.config.SyncMessageVersion,
|
||||
e.config.EnableSSHRoot,
|
||||
e.config.EnableSSHSFTP,
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Ownership is a profile's access policy: the typed owner principals plus the
|
||||
// opt-in shared flag.
|
||||
type Ownership struct {
|
||||
Owners []string
|
||||
Shared bool
|
||||
}
|
||||
|
||||
// GroupResolver resolves a Unix caller's effective group IDs and owner group
|
||||
// names to GIDs. A nil resolver disables group matching.
|
||||
type GroupResolver interface {
|
||||
// CallerGIDs returns the set of group IDs the caller belongs to.
|
||||
CallerGIDs(id Identity) map[uint32]struct{}
|
||||
// GroupNameGID resolves a group name to its GID.
|
||||
GroupNameGID(name string) (uint32, bool)
|
||||
}
|
||||
|
||||
// Authorize reports whether the identity may control a profile with the given
|
||||
// ownership. Privileged callers and shared profiles are always allowed.
|
||||
func Authorize(o Ownership, id Identity, r GroupResolver) bool {
|
||||
if id.IsPrivileged() {
|
||||
return true
|
||||
}
|
||||
if o.Shared {
|
||||
return true
|
||||
}
|
||||
for _, raw := range o.Owners {
|
||||
p, ok := ParsePrincipal(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if principalMatches(p, id, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func principalMatches(p Principal, id Identity, r GroupResolver) bool {
|
||||
switch p.Kind {
|
||||
case KindUID:
|
||||
if id.IsWindows() {
|
||||
return false
|
||||
}
|
||||
uid, err := strconv.ParseUint(p.Value, 10, 32)
|
||||
return err == nil && uint32(uid) == id.UID
|
||||
case KindGID:
|
||||
if id.IsWindows() {
|
||||
return false
|
||||
}
|
||||
gid, err := strconv.ParseUint(p.Value, 10, 32)
|
||||
return err == nil && callerHasGID(uint32(gid), id, r)
|
||||
case KindGroup:
|
||||
if id.IsWindows() || r == nil {
|
||||
return false
|
||||
}
|
||||
gid, ok := r.GroupNameGID(p.Value)
|
||||
return ok && callerHasGID(gid, id, r)
|
||||
case KindSID:
|
||||
if !id.IsWindows() {
|
||||
return false
|
||||
}
|
||||
return id.SID == p.Value || slices.Contains(id.Groups, p.Value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func callerHasGID(gid uint32, id Identity, r GroupResolver) bool {
|
||||
if id.GID == gid {
|
||||
return true
|
||||
}
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := r.CallerGIDs(id)[gid]
|
||||
return ok
|
||||
}
|
||||
@@ -3,20 +3,29 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// NewTransportCredentials returns nil on platforms without a peer-identity
|
||||
// primitive.
|
||||
// errUnsupported is returned on platforms with no local peer-identity
|
||||
// primitive, so consumers fail closed instead of guessing an identity.
|
||||
var errUnsupported = errors.New("peer identity is not available on this platform")
|
||||
|
||||
// NewTransportCredentials returns nil: without a peer-identity primitive the
|
||||
// daemon cannot authenticate local callers, and the caller must treat that as
|
||||
// "authorization cannot be enforced".
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConnIdentity is unsupported on platforms without a peer-identity primitive.
|
||||
func ConnIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, fmt.Errorf("peer identity not supported on %s", runtime.GOOS)
|
||||
// PeerIdentity always fails on this platform.
|
||||
func PeerIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, errUnsupported
|
||||
}
|
||||
|
||||
// ConnIdentity always fails on this platform.
|
||||
func ConnIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, errUnsupported
|
||||
}
|
||||
|
||||
@@ -9,28 +9,33 @@ import (
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that extract the
|
||||
// caller's kernel-authenticated identity from a Unix-socket connection and
|
||||
// expose it via IdentityFromContext. Non-nil on platforms with a
|
||||
// peer-credential primitive.
|
||||
// NewTransportCredentials returns gRPC transport credentials that expose the
|
||||
// caller's kernel-authenticated identity via IdentityFromContext. It returns
|
||||
// nil on platforms that have no peer-identity primitive, which the caller must
|
||||
// treat as "authorization cannot be enforced".
|
||||
//
|
||||
// The handshake exchanges no bytes on the wire, so a client dialing with
|
||||
// insecure credentials interoperates with a server using these. That keeps
|
||||
// older CLI and UI binaries working against an upgraded daemon.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return unixCreds{}
|
||||
}
|
||||
|
||||
// ConnIdentity extracts the caller's identity from an accepted local IPC
|
||||
// connection. It is shared by the gRPC transport credentials and by the JSON
|
||||
// gateway, which reads the identity of its own HTTP clients.
|
||||
func ConnIdentity(conn net.Conn) (Identity, error) {
|
||||
return PeerIdentity(conn)
|
||||
}
|
||||
|
||||
type unixCreds struct{}
|
||||
|
||||
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ConnIdentity extracts the caller's identity from an accepted local IPC
|
||||
// connection. On Unix it reads peer credentials from the socket. It is shared
|
||||
// by the gRPC transport credentials and the JSON gateway (which forwards it).
|
||||
func ConnIdentity(conn net.Conn) (Identity, error) {
|
||||
return PeerIdentity(conn)
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the peer identity and fails closed if it cannot be read.
|
||||
// ServerHandshake extracts the peer identity and fails closed when it cannot
|
||||
// be read, so a connection whose caller is unknown never reaches a handler.
|
||||
func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
id, err := ConnIdentity(conn)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
@@ -17,35 +18,39 @@ var (
|
||||
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
|
||||
)
|
||||
|
||||
// DefaultPipeSDDL keeps the daemon control pipe open to any LOCAL caller,
|
||||
// like Unix socket with 0666 permissions.
|
||||
// DefaultPipeSDDL is the security descriptor for the daemon control pipe.
|
||||
//
|
||||
// D:P protected DACL, no inheritance
|
||||
// (D;;GA;;;NU) deny GENERIC_ALL to NETWORK (remote/SMB)
|
||||
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon itself)
|
||||
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone (local, per-RPC ACL gates)
|
||||
// D:P protected DACL, no inheritance
|
||||
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon's service account)
|
||||
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone
|
||||
//
|
||||
// Any local caller may connect, as with a Unix socket at 0666; what a caller may
|
||||
// actually do is decided from its token, not from the DACL. Remote callers are not
|
||||
// a concern here: winio.ListenPipe creates the pipe with
|
||||
// FILE_PIPE_REJECT_REMOTE_CLIENTS, so NPFS rejects connections from other machines
|
||||
// before the descriptor is consulted.
|
||||
//
|
||||
// A deny ACE on the NETWORK SID would not add anything and would break callers:
|
||||
// that SID is present in any network-logon token, which includes OpenSSH and WinRM
|
||||
// sessions, so it denies administrators driving the CLI over SSH and denies the
|
||||
// daemon itself when started from such a session.
|
||||
func DefaultPipeSDDL() string {
|
||||
return "D:P(D;;GA;;;NU)(A;;GA;;;SY)(A;;GA;;;WD)"
|
||||
return "D:P(A;;GA;;;SY)(A;;GA;;;WD)"
|
||||
}
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that derive the
|
||||
// caller's identity from the named-pipe client token.
|
||||
//
|
||||
// This requires the client to dial at SECURITY_IDENTIFICATION (see dialNamedPipe).
|
||||
// The client must connect at SECURITY_IDENTIFICATION for the daemon to be able
|
||||
// to read its token, which is what DialNamedPipe does.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return winpipeCreds{}
|
||||
}
|
||||
|
||||
type winpipeCreds struct{}
|
||||
|
||||
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ConnIdentity extracts the caller's identity from an accepted named-pipe
|
||||
// connection by impersonating the pipe client and reading its token. It is
|
||||
// shared by the gRPC transport credentials and the JSON gateway (which forwards
|
||||
// it). Requires the client to have connected at SECURITY_IDENTIFICATION.
|
||||
// shared by the gRPC transport credentials and by the JSON gateway, which
|
||||
// reads the identity of its own HTTP clients.
|
||||
func ConnIdentity(conn net.Conn) (Identity, error) {
|
||||
// go-winio's pipe connection embeds *win32File, which exposes Fd().
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
@@ -55,8 +60,15 @@ func ConnIdentity(conn net.Conn) (Identity, error) {
|
||||
return pipeClientIdentity(windows.Handle(fdConn.Fd()))
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the connecting client's identity from the pipe. Fails
|
||||
// closed if the handle or token cannot be read.
|
||||
type winpipeCreds struct{}
|
||||
|
||||
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the connecting client's identity and fails closed
|
||||
// when the handle or token cannot be read, so a connection whose caller is
|
||||
// unknown never reaches a handler.
|
||||
func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
id, err := ConnIdentity(conn)
|
||||
if err != nil {
|
||||
@@ -76,56 +88,103 @@ func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCre
|
||||
|
||||
func (winpipeCreds) OverrideServerName(string) error { return nil }
|
||||
|
||||
// pipeClientIdentity reads the connecting client's user SID, enabled group SIDs,
|
||||
// and elevation by impersonating the pipe client on this thread and reading the
|
||||
// impersonation token.
|
||||
// pipeClientIdentity reads the connecting client's user SID, usable group
|
||||
// SIDs, and elevation state by impersonating the pipe client on this thread
|
||||
// and reading the resulting impersonation token.
|
||||
func pipeClientIdentity(handle windows.Handle) (id Identity, err error) {
|
||||
// Impersonation is per-thread, so the goroutine must stay on this thread
|
||||
// until RevertToSelf, otherwise an unrelated goroutine could inherit the
|
||||
// impersonated context.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
if err = impersonateNamedPipeClient(handle); err != nil {
|
||||
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
|
||||
}
|
||||
// The thread only goes back to the runtime's pool once it is provably no
|
||||
// longer impersonating the client. If the revert fails, leaving it locked
|
||||
// makes Go terminate it when this goroutine exits, which costs one thread
|
||||
// and keeps a thread running as the client from ever being reused.
|
||||
clean := false
|
||||
defer func() {
|
||||
// Surface revert error if there are no other errors.
|
||||
revErr := windows.RevertToSelf()
|
||||
if err == nil {
|
||||
err = revErr
|
||||
if clean {
|
||||
runtime.UnlockOSThread()
|
||||
}
|
||||
}()
|
||||
|
||||
// openAsSelf=true: the token is opened using the daemon's process context
|
||||
// (LocalSystem), not the impersonated client's, so the open always succeeds.
|
||||
if err = impersonateNamedPipeClient(handle); err != nil {
|
||||
clean = true
|
||||
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Surface the revert failure only when nothing else failed: leaving
|
||||
// the thread impersonated is worse than the original error.
|
||||
revErr := windows.RevertToSelf()
|
||||
if revErr != nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("revert impersonation: %w", revErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
clean = true
|
||||
}()
|
||||
|
||||
// openAsSelf=true opens the token with the daemon's own process context
|
||||
// rather than the impersonated client's, so the open cannot fail because
|
||||
// the client lacks access to its own token.
|
||||
var token windows.Token
|
||||
if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil {
|
||||
return Identity{}, fmt.Errorf("open thread token: %w", err)
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
tu, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("get token user: %w", err)
|
||||
}
|
||||
|
||||
tg, err := token.GetTokenGroups()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("get token groups: %w", err)
|
||||
}
|
||||
var groups []string
|
||||
for _, g := range tg.AllGroups() {
|
||||
if g.Attributes&windows.SE_GROUP_ENABLED == 0 || g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
|
||||
continue
|
||||
defer func() {
|
||||
if cerr := token.Close(); cerr != nil {
|
||||
log.Debugf("close client token: %v", cerr)
|
||||
}
|
||||
groups = append(groups, g.Sid.String())
|
||||
}()
|
||||
|
||||
return identityFromToken(token)
|
||||
}
|
||||
|
||||
// identityFromToken reads the user SID, usable group SIDs and elevation state
|
||||
// out of a Windows token.
|
||||
func identityFromToken(token windows.Token) (Identity, error) {
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("read token user: %w", err)
|
||||
}
|
||||
|
||||
groups, err := tokenGroupSIDs(token)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
|
||||
return Identity{
|
||||
SID: tu.User.Sid.String(),
|
||||
SID: user.User.Sid.String(),
|
||||
Groups: groups,
|
||||
Elevated: token.IsElevated(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tokenGroupSIDs returns the SIDs of the groups the token can actually
|
||||
// exercise. Groups that are disabled or marked deny-only are skipped: a
|
||||
// UAC-filtered administrator carries BUILTIN\Administrators as deny-only, and
|
||||
// treating that as membership would hand every admin account privilege it
|
||||
// cannot currently use.
|
||||
func tokenGroupSIDs(token windows.Token) ([]string, error) {
|
||||
tg, err := token.GetTokenGroups()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read token groups: %w", err)
|
||||
}
|
||||
|
||||
var sids []string
|
||||
for _, g := range tg.AllGroups() {
|
||||
if g.Attributes&windows.SE_GROUP_ENABLED == 0 {
|
||||
continue
|
||||
}
|
||||
if g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
|
||||
continue
|
||||
}
|
||||
sids = append(sids, g.Sid.String())
|
||||
}
|
||||
return sids, nil
|
||||
}
|
||||
|
||||
func impersonateNamedPipeClient(h windows.Handle) error {
|
||||
r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
|
||||
if r == 0 {
|
||||
|
||||
@@ -2,26 +2,92 @@ package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Metadata keys used by the local JSON gateway to forward the HTTP client's
|
||||
// identity to the daemon.
|
||||
// Metadata keys the local JSON gateway uses to forward the identity of its own
|
||||
// HTTP client to the daemon. The gateway runs inside the daemon process and
|
||||
// re-dials the daemon over the control socket, so without forwarding every
|
||||
// JSON request would appear to come from the daemon itself.
|
||||
const (
|
||||
mdFwdUID = "x-netbird-fwd-uid" // Unix
|
||||
mdFwdGID = "x-netbird-fwd-gid" // Unix
|
||||
// mdFwd marks a request as forwarded by the JSON gateway. It is always
|
||||
// set, even when the gateway could not read its client's identity, so the
|
||||
// daemon can tell "no identity available" apart from "not forwarded".
|
||||
mdFwd = "x-netbird-fwd"
|
||||
mdFwdUID = "x-netbird-fwd-uid" // Unix user ID
|
||||
mdFwdGID = "x-netbird-fwd-gid" // Unix primary group ID
|
||||
mdFwdSID = "x-netbird-fwd-sid" // Windows user SID
|
||||
mdFwdGroup = "x-netbird-fwd-group" // Windows group SID (repeated)
|
||||
mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" if elevated
|
||||
mdFwdGroup = "x-netbird-fwd-group" // Windows group SID, repeated
|
||||
mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" when elevated
|
||||
|
||||
// mdFwdProof proves the forwarded identity was stamped by this process. The
|
||||
// gateway runs inside the daemon, so a secret held in memory is available to
|
||||
// the only legitimate producer and to nothing else.
|
||||
mdFwdProof = "x-netbird-fwd-proof"
|
||||
)
|
||||
|
||||
// ForwardIdentityMetadata encodes an identity for the gateway to forward to the
|
||||
// daemon.
|
||||
func ForwardIdentityMetadata(id Identity) metadata.MD {
|
||||
// forwardKeys is every metadata key the gateway sets. An HTTP client must never
|
||||
// be able to supply one itself: see IsReservedForwardKey.
|
||||
var forwardKeys = []string{mdFwd, mdFwdUID, mdFwdGID, mdFwdSID, mdFwdGroup, mdFwdElevated, mdFwdProof}
|
||||
|
||||
// forwardProof authenticates the gateway's forwarding metadata. It is generated
|
||||
// once per daemon process and never leaves it: it is not written to disk, not
|
||||
// logged, and not sent anywhere except over the daemon's own control socket to
|
||||
// itself.
|
||||
//
|
||||
// Without it, trusting a forwarded identity rests on every layer in front of it
|
||||
// stripping incoming forwarding keys, and on each key's value shape being
|
||||
// distinguishable from an injected one. A single injected group SID or an
|
||||
// injected "elevated" flag has the same shape as a legitimate one, so no
|
||||
// cardinality rule can catch it. Requiring the proof means metadata that did not
|
||||
// come from this process is refused whatever it contains.
|
||||
var forwardProof = mustForwardProof()
|
||||
|
||||
func mustForwardProof() string {
|
||||
var buf [32]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
// Continuing would leave the forwarded path authenticated by a
|
||||
// predictable value, which is worse than not starting.
|
||||
panic(fmt.Sprintf("generate identity forwarding proof: %v", err))
|
||||
}
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
// IsReservedForwardKey reports whether a gRPC metadata key belongs to the
|
||||
// gateway's identity forwarding, and therefore must be dropped when it arrives
|
||||
// from outside.
|
||||
//
|
||||
// grpc-gateway maps "Grpc-Metadata-<key>" request headers into gRPC metadata and
|
||||
// joins them ahead of the values its own annotators add. Without dropping these,
|
||||
// an HTTP client could hand the daemon "x-netbird-fwd-uid: 0" and be believed,
|
||||
// because the daemon trusts forwarded metadata when the transport peer is the
|
||||
// (privileged) gateway.
|
||||
func IsReservedForwardKey(key string) bool {
|
||||
key = strings.ToLower(key)
|
||||
return slices.Contains(forwardKeys, key)
|
||||
}
|
||||
|
||||
// ForwardIdentityMetadata encodes an HTTP client's identity for the JSON
|
||||
// gateway to forward to the daemon. When known is false only the marker is
|
||||
// set, which makes the daemon treat the caller as unidentified rather than as
|
||||
// the daemon itself.
|
||||
func ForwardIdentityMetadata(id Identity, known bool) metadata.MD {
|
||||
md := metadata.MD{}
|
||||
md.Set(mdFwd, "1")
|
||||
md.Set(mdFwdProof, forwardProof)
|
||||
if !known {
|
||||
return md
|
||||
}
|
||||
|
||||
if id.IsWindows() {
|
||||
md := metadata.MD{}
|
||||
md.Set(mdFwdSID, id.SID)
|
||||
if len(id.Groups) > 0 {
|
||||
md.Set(mdFwdGroup, id.Groups...)
|
||||
@@ -31,47 +97,176 @@ func ForwardIdentityMetadata(id Identity) metadata.MD {
|
||||
}
|
||||
return md
|
||||
}
|
||||
return metadata.Pairs(
|
||||
mdFwdUID, strconv.FormatUint(uint64(id.UID), 10),
|
||||
mdFwdGID, strconv.FormatUint(uint64(id.GID), 10),
|
||||
)
|
||||
|
||||
md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10))
|
||||
md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10))
|
||||
return md
|
||||
}
|
||||
|
||||
// forwardedIdentity extracts a forwarded identity from incoming gRPC metadata
|
||||
// CallerIdentity returns the identity to authorize a request against. For a
|
||||
// direct connection that is the transport peer's kernel identity. For a
|
||||
// request relayed by the local JSON gateway it is the identity the gateway
|
||||
// forwarded, since the transport peer is then the daemon itself.
|
||||
//
|
||||
// A forwarded identity is only honoured when the transport peer is the daemon's
|
||||
// own identity and the metadata carries this process's forwarding proof, so
|
||||
// forged forwarding metadata gains a caller nothing. A forwarded request that
|
||||
// carries no identity is reported as unidentified, never as the daemon.
|
||||
//
|
||||
// The second return value is false when no identity could be established, and
|
||||
// callers MUST fail closed in that case.
|
||||
func CallerIdentity(ctx context.Context) (Identity, bool) {
|
||||
id, ok := IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
// A forwarding key that arrives more than once did not come from the gateway
|
||||
// alone, so nothing about the request can be trusted to describe its caller.
|
||||
// Refusing outright matters because the alternative reading, "not forwarded",
|
||||
// would authorize the request as the transport peer, which on the gateway's
|
||||
// connection is the daemon itself.
|
||||
if duplicatedForwardKey(ctx) {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
forwarded := isForwarded(ctx)
|
||||
|
||||
// Our own process on the other end of the socket is the JSON gateway, the only
|
||||
// thing that dials the daemon from inside it. Such a call must carry a
|
||||
// forwarded identity; without one there is no caller to authorize, and
|
||||
// treating it as the daemon would authorize whatever reached the JSON socket.
|
||||
// Only Linux reports the peer PID, so this is a belt on top of the gateway's
|
||||
// interceptor rather than the sole guarantee.
|
||||
if id.PID != 0 && int(id.PID) == selfPID && !forwarded {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
// Only the gateway's own connection may speak for someone else. Being
|
||||
// privileged is not enough and not the point: the gateway runs inside the
|
||||
// daemon, so it dials as the daemon's identity whatever user that is, which
|
||||
// also covers a rootless container.
|
||||
if !forwarded || !IsDaemonSelf(id) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
// Speaking for someone else additionally requires the proof only this process
|
||||
// holds. Refusing is the only safe reading: the transport peer here is the
|
||||
// daemon itself, so falling back to it would authorize the request as the
|
||||
// daemon. This is also what makes the forwarded values trustworthy once
|
||||
// accepted, so they need no shape checks of their own.
|
||||
if !authenticForward(ctx) {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
return forwardedIdentity(ctx)
|
||||
}
|
||||
|
||||
// duplicatedForwardKey reports whether any forwarding key carries more than one
|
||||
// value. The gateway's interceptor sets each key exactly once and replaces what
|
||||
// was already there, so a repeat means a second source supplied it.
|
||||
func duplicatedForwardKey(ctx context.Context) bool {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, key := range forwardKeys {
|
||||
// Group SIDs are legitimately repeated; the rest identify the caller.
|
||||
if key == mdFwdGroup {
|
||||
continue
|
||||
}
|
||||
if len(md.Get(key)) > 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authenticForward reports whether the request carries this process's forwarding
|
||||
// proof, which only the in-process JSON gateway can supply.
|
||||
func authenticForward(ctx context.Context) bool {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
got := mdSingle(md, mdFwdProof)
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(forwardProof)) == 1
|
||||
}
|
||||
|
||||
// isForwarded reports whether the request carries the JSON gateway marker.
|
||||
func isForwarded(ctx context.Context) bool {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return mdSingle(md, mdFwd) != ""
|
||||
}
|
||||
|
||||
// forwardedIdentity decodes the identity the JSON gateway attached.
|
||||
func forwardedIdentity(ctx context.Context) (Identity, bool) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
if sid := mdFirst(md, mdFwdSID); sid != "" {
|
||||
if sid := mdSingle(md, mdFwdSID); sid != "" {
|
||||
return Identity{
|
||||
SID: sid,
|
||||
SID: sid,
|
||||
// Repeated by design, one value per group, and only reachable once
|
||||
// the forwarding proof has been verified.
|
||||
Groups: md.Get(mdFwdGroup),
|
||||
Elevated: mdFirst(md, mdFwdElevated) == "1",
|
||||
Elevated: mdSingle(md, mdFwdElevated) == "1",
|
||||
}, true
|
||||
}
|
||||
|
||||
uidStr := mdFirst(md, mdFwdUID)
|
||||
if uidStr == "" {
|
||||
return Identity{}, false
|
||||
}
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32)
|
||||
if err != nil {
|
||||
return Identity{}, false
|
||||
}
|
||||
|
||||
id := Identity{UID: uint32(uid)}
|
||||
if g := mdFirst(md, mdFwdGID); g != "" {
|
||||
if v, err := strconv.ParseUint(g, 10, 32); err == nil {
|
||||
id.GID = uint32(v)
|
||||
}
|
||||
if gid, err := strconv.ParseUint(mdSingle(md, mdFwdGID), 10, 32); err == nil {
|
||||
id.GID = uint32(gid)
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func mdFirst(md metadata.MD, key string) string {
|
||||
if v := md.Get(key); len(v) > 0 {
|
||||
// mdSingle returns the value of a forwarded key only when exactly one was
|
||||
// supplied. The gateway's interceptor sets each key exactly once, so more than one
|
||||
// value means something else also supplied it, and the whole identity is treated as
|
||||
// unknown rather than picking a winner. Defence in depth behind the gateway's
|
||||
// header filter.
|
||||
func mdSingle(md metadata.MD, key string) string {
|
||||
if v := md.Get(key); len(v) == 1 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithForwardedIdentity stamps id onto a context's outgoing metadata for the JSON
|
||||
// gateway's call to the daemon, replacing any forwarding keys already present so
|
||||
// values supplied from outside cannot survive alongside it.
|
||||
//
|
||||
// This is deliberately not done with runtime.WithMetadata: grpc-gateway skips its
|
||||
// annotators entirely when no request header maps to metadata ("if len(pairs) == 0
|
||||
// { return ctx, nil, nil }", runtime/context.go), which an HTTP/1.0 request with no
|
||||
// Host header over a unix socket achieves. The daemon would then see an unmarked
|
||||
// call whose transport peer is the daemon's own identity, and authorize it as the
|
||||
// daemon. A client interceptor runs for every RPC regardless of headers.
|
||||
func WithForwardedIdentity(ctx context.Context, id Identity, known bool) context.Context {
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
md = metadata.MD{}
|
||||
} else {
|
||||
md = md.Copy()
|
||||
}
|
||||
|
||||
for _, key := range forwardKeys {
|
||||
delete(md, key)
|
||||
}
|
||||
for key, values := range ForwardIdentityMetadata(id, known) {
|
||||
md[key] = values
|
||||
}
|
||||
|
||||
return metadata.NewOutgoingContext(ctx, md)
|
||||
}
|
||||
|
||||
@@ -4,38 +4,211 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
func TestForwardIdentityRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
id Identity
|
||||
}{
|
||||
{"unix uid/gid", Identity{UID: 1000, GID: 1000}},
|
||||
{"windows sid+groups+elevated", Identity{
|
||||
SID: "S-1-5-21-1-2-3-1001",
|
||||
Groups: []string{"S-1-5-32-544", "S-1-1-0"},
|
||||
Elevated: true,
|
||||
}},
|
||||
{"windows sid only", Identity{SID: "S-1-5-21-9"}},
|
||||
// transportCtx builds a request context as the daemon's transport credentials
|
||||
// would: the identity of whoever opened the socket, plus whatever metadata the
|
||||
// request carried.
|
||||
func transportCtx(id Identity, md metadata.MD) context.Context {
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
},
|
||||
})
|
||||
if md != nil {
|
||||
ctx = metadata.NewIncomingContext(ctx, md)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
return ctx
|
||||
}
|
||||
|
||||
var (
|
||||
root = Identity{UID: 0}
|
||||
unprivUser = Identity{UID: 1000, GID: 1000}
|
||||
)
|
||||
|
||||
// asDaemon pins which identity counts as this process for the duration of a test.
|
||||
// Without it the test binary's own uid decides, which silently changes what
|
||||
// "the gateway" means.
|
||||
func asDaemon(t *testing.T, id Identity) {
|
||||
t.Helper()
|
||||
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
|
||||
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
|
||||
selfIdentity, selfKnown = id, true
|
||||
selfMayDelegate = !id.IsPrivileged()
|
||||
}
|
||||
|
||||
func TestCallerIdentity_DirectConnections(t *testing.T) {
|
||||
t.Run("no transport credentials is not an identity", func(t *testing.T) {
|
||||
if _, ok := CallerIdentity(context.Background()); ok {
|
||||
t.Fatal("a caller with no credentials must not be identified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a direct caller is its transport identity", func(t *testing.T) {
|
||||
id, ok := CallerIdentity(transportCtx(unprivUser, nil))
|
||||
if !ok || id.UID != 1000 {
|
||||
t.Fatalf("got %v ok=%t, want uid 1000", id, ok)
|
||||
}
|
||||
})
|
||||
|
||||
// The whole point of honouring forwarded metadata only from a privileged
|
||||
// transport peer: an unprivileged caller can set any metadata it likes on its
|
||||
// own connection to the daemon socket.
|
||||
t.Run("an unprivileged caller cannot forge an identity", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
forged := metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdGID, "0")
|
||||
id, ok := CallerIdentity(transportCtx(unprivUser, forged))
|
||||
if !ok {
|
||||
t.Fatal("caller should still be identified, as itself")
|
||||
}
|
||||
if id.IsPrivileged() || id.UID != 1000 {
|
||||
t.Fatalf("forged metadata was believed: got %v", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCallerIdentity_GatewayForwarding(t *testing.T) {
|
||||
t.Run("the gateway's client identity is used, not the gateway's own", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := ForwardIdentityMetadata(unprivUser, true)
|
||||
id, ok := CallerIdentity(transportCtx(root, md))
|
||||
if !ok {
|
||||
t.Fatal("forwarded identity should be usable")
|
||||
}
|
||||
if id.IsPrivileged() || id.UID != 1000 {
|
||||
t.Fatalf("got %v, want the forwarded uid 1000 and not privileged", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a privileged gateway client stays privileged", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := ForwardIdentityMetadata(root, true)
|
||||
id, ok := CallerIdentity(transportCtx(root, md))
|
||||
if !ok || !id.IsPrivileged() {
|
||||
t.Fatalf("got %v ok=%t, want a privileged identity", id, ok)
|
||||
}
|
||||
})
|
||||
|
||||
// A JSON socket the gateway cannot read peer credentials from (a TCP socket,
|
||||
// say) must not make every request look like the daemon itself.
|
||||
t.Run("an unreadable client identity is unknown, not the daemon", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := ForwardIdentityMetadata(Identity{}, false)
|
||||
if _, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatal("a forwarded request with no identity must not be identified")
|
||||
}
|
||||
})
|
||||
|
||||
// grpc-gateway turns Grpc-Metadata-<key> headers into gRPC metadata and joins
|
||||
// them ahead of its annotators' values. If an HTTP client's header survived
|
||||
// that, this is the shape the daemon would see: the attacker's uid 0 first,
|
||||
// the real uid second. The gateway filters those headers out, and reading a
|
||||
// duplicated key as unknown makes the daemon safe even if it did not.
|
||||
t.Run("a duplicated key from an injected header is not believed", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := metadata.MD{}
|
||||
md.Append(mdFwd, "1")
|
||||
md.Append(mdFwdUID, "0") // injected by the HTTP client
|
||||
md.Append(mdFwdUID, "1000") // appended by the gateway's annotator
|
||||
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatalf("injected uid was accepted: got %v", id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a duplicated marker is not believed either", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
md := metadata.MD{}
|
||||
md.Append(mdFwd, "1")
|
||||
md.Append(mdFwd, "1")
|
||||
md.Append(mdFwdUID, "1000")
|
||||
// A repeated marker must not be read as "not forwarded": that would
|
||||
// authorize the request as the transport peer, which on the gateway's
|
||||
// connection is the daemon itself.
|
||||
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatalf("a duplicated marker was believed: got %v", id)
|
||||
}
|
||||
})
|
||||
|
||||
// The layers in front of this (the gateway's header matcher, and its
|
||||
// interceptor replacing every forwarding key) are what keep outside metadata
|
||||
// from arriving at all. The proof is what the daemon can check for itself, and
|
||||
// it is the only defence that works for a value whose legitimate shape is
|
||||
// indistinguishable from an injected one: a lone group SID, or "elevated".
|
||||
t.Run("forwarding metadata without this process's proof is refused", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
for name, md := range map[string]metadata.MD{
|
||||
"no proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0"),
|
||||
"wrong proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdProof, "deadbeef"),
|
||||
"windows identity without a proof": metadata.Pairs(mdFwd, "1",
|
||||
mdFwdSID, "S-1-5-21-1-2-3-1001", mdFwdGroup, sidAdministrators, mdFwdElevated, "1"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
|
||||
t.Fatalf("unstamped forwarding metadata was believed: got %v", id)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// A caller that reaches the gateway cannot see the proof, so it cannot append
|
||||
// a group of its own to a genuine forwarded identity: doing so would have to
|
||||
// go through the interceptor, which replaces the whole set.
|
||||
t.Run("a group appended to a stamped identity does not survive the interceptor", func(t *testing.T) {
|
||||
asDaemon(t, root)
|
||||
injected := metadata.MD{}
|
||||
injected.Append(mdFwdGroup, sidAdministrators)
|
||||
|
||||
ctx := WithForwardedIdentity(metadata.NewOutgoingContext(context.Background(), injected),
|
||||
Identity{SID: "S-1-5-21-1-2-3-1001"}, true)
|
||||
out, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
t.Fatal("no outgoing metadata")
|
||||
}
|
||||
if groups := out.Get(mdFwdGroup); len(groups) != 0 {
|
||||
t.Fatalf("injected group survived: %v", groups)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsReservedForwardKey(t *testing.T) {
|
||||
for _, key := range forwardKeys {
|
||||
if !IsReservedForwardKey(key) {
|
||||
t.Errorf("%q must be reserved", key)
|
||||
}
|
||||
}
|
||||
|
||||
// grpc-gateway canonicalises header names, so the check has to be
|
||||
// case-insensitive.
|
||||
if !IsReservedForwardKey("X-Netbird-Fwd-Uid") {
|
||||
t.Error("the check must be case-insensitive")
|
||||
}
|
||||
|
||||
for _, key := range []string{"authorization", "x-netbird", "x-netbird-fwd-uid-extra", ""} {
|
||||
if IsReservedForwardKey(key) {
|
||||
t.Errorf("%q must not be reserved", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardIdentityMetadata_AlwaysMarksForwarded(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
id Identity
|
||||
known bool
|
||||
}{
|
||||
{"known unix identity", unprivUser, true},
|
||||
{"unknown identity", Identity{}, false},
|
||||
{"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := metadata.NewIncomingContext(context.Background(), ForwardIdentityMetadata(tc.id))
|
||||
got, ok := forwardedIdentity(ctx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, tc.id, got)
|
||||
md := ForwardIdentityMetadata(tc.id, tc.known)
|
||||
if got := md.Get(mdFwd); len(got) != 1 || got[0] != "1" {
|
||||
t.Fatalf("marker = %v, want exactly one \"1\"", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardedIdentity_None(t *testing.T) {
|
||||
_, ok := forwardedIdentity(context.Background())
|
||||
assert.False(t, ok, "no metadata, no forwarded identity")
|
||||
|
||||
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("other", "x"))
|
||||
_, ok = forwardedIdentity(ctx)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,59 @@
|
||||
// Package ipcauth provides the kernel-authenticated identity of a local IPC
|
||||
// (gRPC) caller and the transport credentials that surface it into the gRPC
|
||||
// context, so the daemon can authorize each RPC by caller identity.
|
||||
// context, so the daemon can authorize individual RPCs by caller identity.
|
||||
//
|
||||
// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
|
||||
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the named-pipe
|
||||
// client token. Platforms without a peer-identity primitive get no credentials
|
||||
// and therefore no enforcement.
|
||||
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the
|
||||
// named-pipe client token. Platforms without a peer-identity primitive get no
|
||||
// credentials, and every consumer must fail closed when no identity is
|
||||
// available.
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
// sidLocalSystem is the well-known Windows SID for the LocalSystem account.
|
||||
const sidLocalSystem = "S-1-5-18"
|
||||
// Well-known Windows SIDs that identify a fully privileged principal.
|
||||
const (
|
||||
sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM
|
||||
sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE
|
||||
sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE
|
||||
sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators
|
||||
)
|
||||
|
||||
// Identity is the kernel-authenticated identity of a local IPC caller. The zero
|
||||
// value is not a valid identity.
|
||||
// Identity is the kernel-authenticated identity of a local IPC caller. The
|
||||
// zero value is not a valid identity: consumers must only use one obtained
|
||||
// with a true ok/nil error return.
|
||||
type Identity struct {
|
||||
// UID and GID are the caller's Unix user ID and primary group ID.
|
||||
// Zero on Windows, where SID is authoritative instead.
|
||||
// UID and GID are the caller's Unix user ID and primary group ID. Both are
|
||||
// zero on Windows, where SID is authoritative instead.
|
||||
UID uint32
|
||||
GID uint32
|
||||
|
||||
// SID is the caller's Windows security identifier (empty on Unix).
|
||||
// SID is the caller's Windows security identifier, empty on Unix.
|
||||
SID string
|
||||
|
||||
// Groups holds the caller's Windows group SIDs, captured from the client
|
||||
// token at handshake (empty on Unix, where supplementary group membership is
|
||||
// resolved on demand via NSS/getent by the authorizer).
|
||||
// token at handshake time. Only groups that are enabled and not
|
||||
// deny-only are captured, so a group listed here is one the caller can
|
||||
// actually exercise. Empty on Unix.
|
||||
Groups []string
|
||||
|
||||
// Elevated reports whether the Windows client token is elevated (run as
|
||||
// administrator). Always false on Unix, where privilege is uid==0.
|
||||
// Elevated reports whether the Windows client token is elevated (running
|
||||
// as administrator, or an administrator with UAC turned off). Always false
|
||||
// on Unix, where privilege is uid 0.
|
||||
Elevated bool
|
||||
|
||||
// PID is the caller's process ID where the platform reports it (Linux's
|
||||
// SO_PEERCRED), and 0 where it does not. It identifies the daemon's own
|
||||
// process dialling itself, which is what the JSON gateway does, and is never
|
||||
// used to grant anything.
|
||||
PID int32
|
||||
}
|
||||
|
||||
// IsWindows reports whether this identity is a Windows principal (SID-based)
|
||||
@@ -47,15 +63,35 @@ func (i Identity) IsWindows() bool {
|
||||
}
|
||||
|
||||
// IsPrivileged reports whether the caller is the platform's administrative
|
||||
// principal.
|
||||
// principal, which is what the daemon requires for changes that cross the
|
||||
// user-to-root boundary.
|
||||
//
|
||||
// On Windows the decision comes from the caller's token rather than from
|
||||
// account names or group RIDs: an elevated token, one of the service accounts
|
||||
// the daemon itself may run as, or a token with BUILTIN\Administrators
|
||||
// enabled. A UAC-filtered administrator has that group marked deny-only, and
|
||||
// deny-only groups are dropped when the identity is captured, so such a
|
||||
// caller is correctly reported as unprivileged. Domain group memberships
|
||||
// (Domain Admins and friends) are deliberately not consulted: they say
|
||||
// nothing about what this token may do on this machine.
|
||||
func (i Identity) IsPrivileged() bool {
|
||||
if i.IsWindows() {
|
||||
return i.Elevated || i.SID == sidLocalSystem
|
||||
if !i.IsWindows() {
|
||||
return i.UID == 0
|
||||
}
|
||||
return i.UID == 0
|
||||
|
||||
if i.Elevated {
|
||||
return true
|
||||
}
|
||||
|
||||
switch i.SID {
|
||||
case sidLocalSystem, sidLocalService, sidNetworkService:
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.Contains(i.Groups, sidAdministrators)
|
||||
}
|
||||
|
||||
// String renders the identity for audit logs.
|
||||
// String renders the identity for audit logs and denial messages.
|
||||
func (i Identity) String() string {
|
||||
if i.IsWindows() {
|
||||
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
|
||||
@@ -63,8 +99,8 @@ func (i Identity) String() string {
|
||||
return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
|
||||
}
|
||||
|
||||
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so the
|
||||
// interceptor can retrieve it from the request context via IdentityFromContext.
|
||||
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so
|
||||
// handlers can retrieve it from the request context via IdentityFromContext.
|
||||
type AuthInfo struct {
|
||||
credentials.CommonAuthInfo
|
||||
Identity Identity
|
||||
@@ -74,8 +110,10 @@ type AuthInfo struct {
|
||||
func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
|
||||
|
||||
// IdentityFromContext extracts the caller's kernel-authenticated identity from
|
||||
// the gRPC peer context. The second return value is false when no IPC transport
|
||||
// credentials were negotiated, callers MUST fail closed in that case.
|
||||
// the gRPC peer context. The second return value is false when no IPC
|
||||
// transport credentials were negotiated, which happens on a TCP daemon socket
|
||||
// and on platforms without a peer-identity primitive. Callers MUST fail closed
|
||||
// in that case.
|
||||
func IdentityFromContext(ctx context.Context) (Identity, bool) {
|
||||
p, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
func TestIdentityFromContext_NoPeer(t *testing.T) {
|
||||
_, ok := IdentityFromContext(context.Background())
|
||||
assert.False(t, ok, "bare context must report no identity (fail closed)")
|
||||
}
|
||||
|
||||
func TestIdentityFromContext_WrongAuthInfo(t *testing.T) {
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{})
|
||||
_, ok := IdentityFromContext(ctx)
|
||||
assert.False(t, ok, "peer without our AuthInfo must report no identity")
|
||||
}
|
||||
|
||||
func TestIdentityFromContext_Present(t *testing.T) {
|
||||
want := Identity{UID: 1000, GID: 1000}
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: want,
|
||||
},
|
||||
})
|
||||
|
||||
got, ok := IdentityFromContext(ctx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestIdentity_IsPrivileged(t *testing.T) {
|
||||
// Unix
|
||||
assert.True(t, Identity{UID: 0}.IsPrivileged(), "root is privileged")
|
||||
assert.False(t, Identity{UID: 1000}.IsPrivileged(), "non-root is not privileged")
|
||||
// Windows
|
||||
assert.True(t, Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}.IsPrivileged(), "elevated admin is privileged")
|
||||
assert.True(t, Identity{SID: "S-1-5-18"}.IsPrivileged(), "LocalSystem is privileged")
|
||||
assert.False(t, Identity{SID: "S-1-5-21-1-2-3-1001"}.IsPrivileged(), "non-elevated admin is NOT privileged")
|
||||
}
|
||||
|
||||
func TestIdentity_IsWindows(t *testing.T) {
|
||||
assert.True(t, Identity{SID: "S-1-5-18"}.IsWindows())
|
||||
assert.False(t, Identity{UID: 0}.IsWindows())
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Interceptor enforces per-RPC authorization on the daemon IPC, keyed to
|
||||
// the caller's kernel-authenticated identity. It is safe-by-default:
|
||||
// any RPC without a matching bypass is gated by the active profile's ownership,
|
||||
// and a caller without a readable identity is denied.
|
||||
type Interceptor struct {
|
||||
policy ProfilePolicy
|
||||
resolver GroupResolver
|
||||
// selfUID is the daemon's own effective UID. -1 on Windows.
|
||||
selfUID int
|
||||
}
|
||||
|
||||
// NewInterceptor builds an interceptor over the given policy and group resolver.
|
||||
func NewInterceptor(policy ProfilePolicy, resolver GroupResolver) *Interceptor {
|
||||
return &Interceptor{policy: policy, resolver: resolver, selfUID: os.Geteuid()}
|
||||
}
|
||||
|
||||
// UnaryServerInterceptor authorizes each unary RPC before the handler runs.
|
||||
func (i *Interceptor) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
if err := i.authorize(ctx, info.FullMethod); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler(ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
// StreamServerInterceptor authorizes each streaming RPC before the handler runs.
|
||||
func (i *Interceptor) StreamServerInterceptor() grpc.StreamServerInterceptor {
|
||||
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
if err := i.authorize(ss.Context(), info.FullMethod); err != nil {
|
||||
return err
|
||||
}
|
||||
return handler(srv, ss)
|
||||
}
|
||||
}
|
||||
|
||||
// authorize decides each RPC from the caller's identity, first match wins:
|
||||
//
|
||||
// 0. no identity DENY
|
||||
// 1. self / privileged / forwarded ALLOW (root, elevated, daemon-self, JSON gateway)
|
||||
// 2. in ownersAuthorizedMethods owner tier (gate on DaemonOwnership)
|
||||
// 3. in handlerAuthorizedMethods profile tier (bypass, handler decides)
|
||||
// 4. everything else default tier (gate on ActiveProfileOwnership)
|
||||
func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error {
|
||||
id, ok := IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
log.Warnf("ipc authz: DENY %s. caller identity unavailable", fullMethod)
|
||||
return status.Error(codes.PermissionDenied, "caller identity could not be verified on the daemon control channel")
|
||||
}
|
||||
|
||||
if i.isSelfOrPrivileged(id) {
|
||||
// The local JSON gateway connects as the daemon itself (self/privileged)
|
||||
// and forwards the real HTTP client's identity. Trust it here, where
|
||||
// the transport peer is already the daemon, then authorize as the
|
||||
// forwarded client. A direct non-privileged caller never reaches this
|
||||
// branch, so it cannot forge the forwarding metadata.
|
||||
fwd, hasFwd := forwardedIdentity(ctx)
|
||||
if !hasFwd {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
log.Infof("ipc authz: gateway-forwarded identity %s", fwd)
|
||||
id = fwd
|
||||
if i.isSelfOrPrivileged(id) {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Owner tier: daemon-level RPCs (Add, Down, Status) require a daemon-wide owner.
|
||||
if ownersAuthorizedMethods[fullMethod] {
|
||||
allowed, err := i.authorizeOwnership(id, i.policy.DaemonOwnership, i.policy.ClaimDaemonOwnerIfUnowned)
|
||||
if err != nil {
|
||||
log.Errorf("ipc authz: claim daemon owner for %s: %v", id, err)
|
||||
return status.Error(codes.Internal, "failed to claim daemon ownership")
|
||||
}
|
||||
if allowed {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
log.Warnf("ipc authz: DENY %s for %s. not a daemon owner", fullMethod, id)
|
||||
return status.Errorf(codes.PermissionDenied,
|
||||
"not authorized (caller %s is not a daemon owner). ask an owner or run as root/administrator", id)
|
||||
}
|
||||
|
||||
// Profile tier: the handler self-authorizes against the target profile.
|
||||
if handlerAuthorizedMethods[fullMethod] {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Default: gated on the active profile's ownership.
|
||||
allowed, err := i.authorizeOwnership(id, i.policy.ActiveProfileOwnership, i.policy.ClaimActiveProfileOwnerIfUnowned)
|
||||
if err != nil {
|
||||
log.Errorf("ipc authz: claim active profile for %s: %v", id, err)
|
||||
return status.Error(codes.Internal, "failed to claim profile ownership")
|
||||
}
|
||||
if allowed {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Warnf("ipc authz: DENY %s for %s. active profile owned by another principal", fullMethod, id)
|
||||
return status.Errorf(codes.PermissionDenied,
|
||||
"not authorized to control the active profile (caller %s). ask an owner or run as root/administrator", id)
|
||||
}
|
||||
|
||||
// authorizeOwnership authorizes id against an ownership set, claiming it via
|
||||
// trust-on-first-use when it is unowned and unshared. The claim is atomic, so on
|
||||
// a lost race it re-reads and authorizes normally.
|
||||
func (i *Interceptor) authorizeOwnership(id Identity, get func() Ownership, claim func(Identity) (bool, error)) (bool, error) {
|
||||
o := get()
|
||||
if len(o.Owners) == 0 && !o.Shared {
|
||||
claimed, err := claim(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if claimed {
|
||||
return true, nil
|
||||
}
|
||||
o = get()
|
||||
}
|
||||
return Authorize(o, id, i.resolver), nil
|
||||
}
|
||||
|
||||
// isSelfOrPrivileged reports whether the caller is the platform administrator
|
||||
// (root / elevated-admin / LocalSystem) or the daemon's own user.
|
||||
func (i *Interceptor) isSelfOrPrivileged(id Identity) bool {
|
||||
if id.IsPrivileged() {
|
||||
return true
|
||||
}
|
||||
// Daemon-self: only meaningful on Unix (Windows privilege is covered above).
|
||||
return !id.IsWindows() && i.selfUID >= 0 && int(id.UID) == i.selfUID
|
||||
}
|
||||
|
||||
func (i *Interceptor) auditAllow(id Identity, fullMethod string) {
|
||||
if auditMethods[fullMethod] {
|
||||
log.Infof("ipc authz: allow %s for %s", fullMethod, id)
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type mockPolicy struct {
|
||||
o Ownership // active profile ownership
|
||||
daemon Ownership // daemon-wide ownership
|
||||
claimed bool
|
||||
daemonClaimed bool
|
||||
}
|
||||
|
||||
func (m *mockPolicy) ActiveProfileOwnership() Ownership { return m.o }
|
||||
|
||||
// ClaimActiveProfileOwnerIfUnowned records a claim and marks the profile owned.
|
||||
func (m *mockPolicy) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
|
||||
if len(m.o.Owners) == 0 && !m.o.Shared {
|
||||
m.o.Owners = []string{OwnerPrincipalForIdentity(id)}
|
||||
m.claimed = true
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *mockPolicy) DaemonOwnership() Ownership { return m.daemon }
|
||||
|
||||
// ClaimDaemonOwnerIfUnowned records a daemon claim and marks the daemon owned.
|
||||
func (m *mockPolicy) ClaimDaemonOwnerIfUnowned(id Identity) (bool, error) {
|
||||
if len(m.daemon.Owners) == 0 && !m.daemon.Shared {
|
||||
m.daemon.Owners = []string{OwnerPrincipalForIdentity(id)}
|
||||
m.daemonClaimed = true
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type mockResolver struct {
|
||||
gids map[uint32]struct{}
|
||||
names map[string]uint32
|
||||
}
|
||||
|
||||
func (m mockResolver) CallerGIDs(Identity) map[uint32]struct{} { return m.gids }
|
||||
func (m mockResolver) GroupNameGID(n string) (uint32, bool) { g, ok := m.names[n]; return g, ok }
|
||||
|
||||
func ctxWith(id Identity) context.Context {
|
||||
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: AuthInfo{Identity: id}})
|
||||
}
|
||||
|
||||
const (
|
||||
up = servicePath + "Up"
|
||||
list = servicePath + "ListProfiles"
|
||||
unkwn = servicePath + "SomeFutureMethod"
|
||||
down = servicePath + "Down"
|
||||
statusm = servicePath + "Status"
|
||||
addp = servicePath + "AddProfile"
|
||||
switchp = servicePath + "SwitchProfile"
|
||||
addowner = servicePath + "AddOwner"
|
||||
sharep = servicePath + "ShareProfile"
|
||||
)
|
||||
|
||||
func TestInterceptorAuthorize(t *testing.T) {
|
||||
const selfUID = 4000
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
own Ownership // active profile ownership
|
||||
daemon Ownership // daemon-wide ownership
|
||||
resolver GroupResolver
|
||||
ctx context.Context
|
||||
method string
|
||||
wantErr bool
|
||||
}{
|
||||
// Default gate (active profile ownership).
|
||||
{"no identity denies", Ownership{}, Ownership{}, nil, context.Background(), up, true},
|
||||
{"root allowed", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: 0}), up, false},
|
||||
{"daemon-self allowed", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: selfUID}), up, false},
|
||||
{"shared allows any", Ownership{Shared: true}, Ownership{}, nil, ctxWith(Identity{UID: 1234}), up, false},
|
||||
{"uid owner allowed", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 1000}), up, false},
|
||||
{"non-owner denied", Ownership{Owners: []string{"uid:1000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), up, true},
|
||||
{"unknown method gated", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), unkwn, true},
|
||||
{"primary gid owner", Ownership{Owners: []string{"gid:5000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000, GID: 5000}), up, false},
|
||||
{"group-name owner via resolver", Ownership{Owners: []string{"group:admins"}}, Ownership{},
|
||||
mockResolver{names: map[string]uint32{"admins": 5000}, gids: map[uint32]struct{}{5000: {}}},
|
||||
ctxWith(Identity{UID: 2000, GID: 42}), up, false},
|
||||
{"windows sid owner", Ownership{Owners: []string{"sid:S-1-5-21-9"}}, Ownership{}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-9"}), up, false},
|
||||
{"windows group-sid owner", Ownership{Owners: []string{"sid:S-1-5-32-544"}}, Ownership{}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-1", Groups: []string{"S-1-5-32-544"}}), up, false},
|
||||
{"windows elevated privileged", Ownership{}, Ownership{}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-1", Elevated: true}), up, false},
|
||||
|
||||
// Profile tier (handler self-authorizes, bypass).
|
||||
{"list bypasses gate", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), list, false},
|
||||
{"switch-profile bypasses gate", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), switchp, false},
|
||||
|
||||
// Owner tier (daemon-wide ownership), independent of the active profile.
|
||||
{"down by daemon owner allowed", Ownership{Owners: []string{"uid:9"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), down, false},
|
||||
{"down by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), down, true},
|
||||
{"status by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), statusm, true},
|
||||
{"add by daemon owner allowed", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), addp, false},
|
||||
{"add by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), addp, true},
|
||||
{"owner-tier TOFU claims unowned daemon", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), down, false},
|
||||
|
||||
// Owner-set mutations gate on daemon ownership, not the active profile.
|
||||
{"add-owner by daemon owner allowed", Ownership{Owners: []string{"uid:9"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), addowner, false},
|
||||
{"add-owner by active-profile owner (non daemon owner) denied", Ownership{Owners: []string{"uid:2000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), addowner, true},
|
||||
{"share by active-profile owner (non daemon owner) denied", Ownership{Owners: []string{"uid:2000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), sharep, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
i := &Interceptor{policy: &mockPolicy{o: tt.own, daemon: tt.daemon}, resolver: tt.resolver, selfUID: selfUID}
|
||||
err := i.authorize(tt.ctx, tt.method)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptorForwardedIdentity verifies the JSON-gateway trust model: a
|
||||
// self/privileged transport peer (the loopback gateway) may forward a real
|
||||
// client identity, but a non-privileged caller cannot forge it.
|
||||
func TestInterceptorForwardedIdentity(t *testing.T) {
|
||||
const selfUID = 4000
|
||||
owners := Ownership{Owners: []string{"uid:1000"}}
|
||||
|
||||
withFwd := func(peerUID, fwdUID uint32) context.Context {
|
||||
ctx := ctxWith(Identity{UID: peerUID})
|
||||
return metadata.NewIncomingContext(ctx, metadata.Pairs(mdFwdUID, itoa(fwdUID)))
|
||||
}
|
||||
|
||||
// Gateway forwards a non-owner client: denied as that client.
|
||||
i := &Interceptor{policy: &mockPolicy{o: owners}, selfUID: selfUID}
|
||||
assert.Error(t, i.authorize(withFwd(selfUID, 2000), up))
|
||||
|
||||
// Gateway forwards the owner: allowed.
|
||||
assert.NoError(t, i.authorize(withFwd(selfUID, 1000), up))
|
||||
|
||||
// A non-privileged direct caller's forwarded metadata: denied
|
||||
assert.Error(t, i.authorize(withFwd(2000, 1000), up))
|
||||
}
|
||||
|
||||
func itoa(u uint32) string {
|
||||
return strconv.FormatUint(uint64(u), 10)
|
||||
}
|
||||
|
||||
// TestInterceptorTOFU verifies an unowned, non-shared profile is claimed by the
|
||||
// first non-privileged caller, and a different caller is then denied.
|
||||
func TestInterceptorTOFU(t *testing.T) {
|
||||
policy := &mockPolicy{o: Ownership{}} // unowned
|
||||
i := &Interceptor{policy: policy, resolver: nil, selfUID: 4000}
|
||||
|
||||
// First caller (uid 1000) claims via TOFU.
|
||||
err := i.authorize(ctxWith(Identity{UID: 1000}), up)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, policy.claimed, "first caller should claim ownership")
|
||||
assert.Equal(t, []string{"uid:1000"}, policy.o.Owners)
|
||||
|
||||
// A different caller is now denied (profile owned by uid 1000).
|
||||
err = i.authorize(ctxWith(Identity{UID: 2000}), up)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -10,13 +10,15 @@ import (
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket connection via LOCAL_PEERCRED (xucred). xucred
|
||||
// carries the uid and primary group.
|
||||
func PeerIdentity(c net.Conn) (Identity, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
// other end of a Unix socket via LOCAL_PEERCRED. The xucred is recorded by the
|
||||
// kernel at connect() time and carries the peer's uid and its group list, of
|
||||
// which the first entry is the primary group.
|
||||
func PeerIdentity(conn net.Conn) (Identity, error) {
|
||||
uc, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
|
||||
}
|
||||
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("raw conn: %w", err)
|
||||
@@ -27,14 +29,13 @@ func PeerIdentity(c net.Conn) (Identity, error) {
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
|
||||
}); err != nil {
|
||||
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
|
||||
return Identity{}, fmt.Errorf("control raw conn: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("LOCAL_PEERCRED: %w", credErr)
|
||||
return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
id := Identity{UID: cred.Uid}
|
||||
// Groups[0] is the effective (primary) GID; guard against an empty list.
|
||||
if cred.Ngroups > 0 {
|
||||
id.GID = cred.Groups[0]
|
||||
}
|
||||
|
||||
@@ -10,14 +10,15 @@ import (
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket connection via SO_PEERCRED. The credentials are
|
||||
// captured by the kernel at connect() time and cannot be spoofed or changed for
|
||||
// the life of the connection.
|
||||
func PeerIdentity(c net.Conn) (Identity, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
// other end of a Unix socket via SO_PEERCRED. The credentials are recorded by
|
||||
// the kernel at connect() time and cannot be changed for the life of the
|
||||
// connection, so they are not spoofable by the caller.
|
||||
func PeerIdentity(conn net.Conn) (Identity, error) {
|
||||
uc, ok := conn.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
|
||||
}
|
||||
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("raw conn: %w", err)
|
||||
@@ -28,14 +29,11 @@ func PeerIdentity(c net.Conn) (Identity, error) {
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
||||
}); err != nil {
|
||||
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
|
||||
return Identity{}, fmt.Errorf("control raw conn: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("SO_PEERCRED: %w", credErr)
|
||||
return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
return Identity{
|
||||
UID: cred.Uid,
|
||||
GID: cred.Gid,
|
||||
}, nil
|
||||
return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build !linux && !darwin && !freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// PeerIdentity is unimplemented on platforms without a Unix-socket peer-credential
|
||||
// primitive. Windows derives identity from the named-pipe client token instead
|
||||
// (see the Windows transport credentials), so it never calls this.
|
||||
func PeerIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, fmt.Errorf("peer credential check not supported on %s", runtime.GOOS)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// TestPeerIdentity_MatchesCurrentProcess connects to a real Unix socket and
|
||||
// verifies the extracted UID/GID match the running process (both ends are us).
|
||||
func TestPeerIdentity_MatchesCurrentProcess(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "peer.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
type result struct {
|
||||
id Identity
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
c, aerr := ln.Accept()
|
||||
if aerr != nil {
|
||||
done <- result{err: aerr}
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
id, ierr := PeerIdentity(c)
|
||||
done <- result{id: id, err: ierr}
|
||||
}()
|
||||
|
||||
client, err := net.Dial("unix", sock)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
res := <-done
|
||||
require.NoError(t, res.err)
|
||||
assert.Equal(t, uint32(os.Getuid()), res.id.UID, "UID should match current process")
|
||||
assert.Equal(t, uint32(os.Getgid()), res.id.GID, "primary GID should match current process")
|
||||
}
|
||||
|
||||
// TestPeerIdentity_NonUnixConn rejects non-Unix connections (fail closed).
|
||||
func TestPeerIdentity_NonUnixConn(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
c, aerr := ln.Accept()
|
||||
if aerr != nil {
|
||||
done <- aerr
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
_, ierr := PeerIdentity(c)
|
||||
done <- ierr
|
||||
}()
|
||||
|
||||
client, err := net.Dial("tcp", ln.Addr().String())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
assert.Error(t, <-done, "PeerIdentity must reject a non-Unix connection")
|
||||
}
|
||||
|
||||
// TestGRPCRoundTrip_ServerCredsClientInsecure proves the transport contract end
|
||||
// to end: a gRPC server using the peercred transport credentials still serves a
|
||||
// plain insecure client (the CLI never changed), and the caller's kernel
|
||||
// identity reaches the handler via IdentityFromContext.
|
||||
func TestGRPCRoundTrip_ServerCredsClientInsecure(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "rt.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
require.NoError(t, err)
|
||||
|
||||
var gotUID uint32
|
||||
var gotOK bool
|
||||
srv := grpc.NewServer(
|
||||
grpc.Creds(NewTransportCredentials()),
|
||||
grpc.UnaryInterceptor(func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) {
|
||||
id, ok := IdentityFromContext(ctx)
|
||||
gotUID, gotOK = id.UID, ok
|
||||
return h(ctx, req)
|
||||
}),
|
||||
)
|
||||
healthpb.RegisterHealthServer(srv, health.NewServer())
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
t.Cleanup(srv.Stop)
|
||||
|
||||
conn, err := grpc.NewClient("unix://"+sock, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{})
|
||||
require.NoError(t, err, "insecure client must reach the peercred server")
|
||||
|
||||
assert.True(t, gotOK, "handler must see a peer identity")
|
||||
assert.Equal(t, uint32(os.Getuid()), gotUID, "handler must see the caller's UID")
|
||||
}
|
||||
87
client/internal/ipcauth/pipeserver_windows.go
Normal file
87
client/internal/ipcauth/pipeserver_windows.go
Normal file
@@ -0,0 +1,87 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// PipeServerTrusted reports an error unless the pipe behind conn was created by a
|
||||
// principal this client may hand secrets to. Clients call it for a pipe whose name
|
||||
// carries no guarantee of its own, which is any name outside the
|
||||
// ProtectedPrefix\Administrators namespace: that namespace already restricts
|
||||
// creation to administrators and LocalSystem, while a plain name can be created by
|
||||
// any local user before the daemon gets there.
|
||||
//
|
||||
// The decision is made from the pipe object's owner, not from the serving process,
|
||||
// because a client cannot open a process running as another user at all, and the
|
||||
// legitimate case is precisely an unprivileged client talking to a privileged
|
||||
// daemon. Trusted owners are the service accounts, BUILTIN\Administrators, and
|
||||
// this client's own user, the last of which is the daemon a user runs themselves
|
||||
// as in netstack mode. A pipe owned by anyone else gets no setup key, pre-shared
|
||||
// key or SSO prompt out of this client.
|
||||
func PipeServerTrusted(conn net.Conn) error {
|
||||
// go-winio's pipe connection embeds *win32File, which exposes Fd().
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return fmt.Errorf("connection %T does not expose a pipe handle", conn)
|
||||
}
|
||||
|
||||
owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !trustedPipeOwner(owner) {
|
||||
return fmt.Errorf("pipe owned by %s, which is neither an administrator nor this user", owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PipeOwnedBySelf reports whether the pipe behind conn was created by this very
|
||||
// user, which is how a client recognises a daemon running as itself. Ownership it
|
||||
// cannot read is reported as false.
|
||||
func PipeOwnedBySelf(conn net.Conn) bool {
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
|
||||
if err != nil {
|
||||
log.Debugf("read daemon pipe owner: %v", err)
|
||||
return false
|
||||
}
|
||||
return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
|
||||
}
|
||||
|
||||
// pipeOwnerSID reads the owner of the pipe object a client is connected to. The
|
||||
// handle was opened with GENERIC_READ, which includes READ_CONTROL, so no extra
|
||||
// access is needed.
|
||||
func pipeOwnerSID(handle windows.Handle) (string, error) {
|
||||
sd, err := windows.GetSecurityInfo(handle, windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read pipe security info: %w", err)
|
||||
}
|
||||
|
||||
owner, _, err := sd.Owner()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read pipe owner: %w", err)
|
||||
}
|
||||
return owner.String(), nil
|
||||
}
|
||||
|
||||
// trustedPipeOwner reports whether a pipe's owner is a principal a client may
|
||||
// speak to. An elevated process's objects are owned by BUILTIN\Administrators by
|
||||
// default, an unelevated one's by the user, which is why both forms appear here.
|
||||
func trustedPipeOwner(owner string) bool {
|
||||
switch owner {
|
||||
case sidLocalSystem, sidLocalService, sidNetworkService, sidAdministrators:
|
||||
return true
|
||||
}
|
||||
return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user