mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-28 23:39:07 +01:00
Merge branch 'main' into feature/policy-by-public-id
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.25-bookworm
|
FROM golang:1.26.7-bookworm
|
||||||
|
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
&& apt-get -y install --no-install-recommends\
|
&& apt-get -y install --no-install-recommends\
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ on:
|
|||||||
AWS issues it. Leave empty for the Sonnet 4.6 default.
|
AWS issues it. Leave empty for the Sonnet 4.6 default.
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
test_pattern:
|
||||||
|
description: >-
|
||||||
|
Package pattern to run. Defaults to the whole suite; narrow it to one
|
||||||
|
package (e.g. ./e2e/agentnetwork/...) when a run only needs that
|
||||||
|
package's answer and not the sixteen minutes the container suite costs.
|
||||||
|
required: false
|
||||||
|
default: "./e2e/..."
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -77,4 +84,8 @@ jobs:
|
|||||||
GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }}
|
GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }}
|
||||||
GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }}
|
GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }}
|
||||||
GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }}
|
GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }}
|
||||||
run: go test -tags e2e -timeout 40m -v ./e2e/...
|
# Read through an env var rather than interpolated into the run
|
||||||
|
# script: a dispatch input reaching a shell command directly is a
|
||||||
|
# script-injection seam, however trusted the dispatcher.
|
||||||
|
TEST_PATTERN: ${{ inputs.test_pattern || './e2e/...' }}
|
||||||
|
run: go test -tags e2e -timeout 40m -v "$TEST_PATTERN"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: protobuf checks
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "release-*"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/buf.yml"
|
||||||
|
- "**/buf.yaml"
|
||||||
|
- "**/buf.lock"
|
||||||
|
- "**/buf.gen.yaml"
|
||||||
|
- "**.proto"
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
jobs:
|
||||||
|
buf:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: bufbuild/buf-action@8c6a16e16f12ba20b6470afa9c2ba9b5ba8c97c3 # v1.5.0
|
||||||
|
with:
|
||||||
|
push: false
|
||||||
|
archive: false
|
||||||
|
pr_comment: false
|
||||||
|
build: false
|
||||||
|
lint: false
|
||||||
|
format: false
|
||||||
|
breaking: true
|
||||||
@@ -233,7 +233,7 @@ jobs:
|
|||||||
-e GOCACHE=${CONTAINER_GOCACHE} \
|
-e GOCACHE=${CONTAINER_GOCACHE} \
|
||||||
-e GOMODCACHE=${CONTAINER_GOMODCACHE} \
|
-e GOMODCACHE=${CONTAINER_GOMODCACHE} \
|
||||||
-e CONTAINER=${CONTAINER} \
|
-e CONTAINER=${CONTAINER} \
|
||||||
golang:1.25-alpine \
|
golang:1.26.7-alpine \
|
||||||
sh -c ' \
|
sh -c ' \
|
||||||
apk update; apk add --no-cache \
|
apk update; apk add --no-cache \
|
||||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
||||||
@@ -730,6 +730,11 @@ jobs:
|
|||||||
- name: Install modules
|
- name: Install modules
|
||||||
run: go mod tidy
|
run: go mod tidy
|
||||||
|
|
||||||
|
- name: Run Mage
|
||||||
|
uses: magefile/mage-action@a662bd8c29d8106879588cfff83b2faf6e6f59db # v4.0.0
|
||||||
|
with:
|
||||||
|
install-only: true
|
||||||
|
|
||||||
- name: check git status
|
- name: check git status
|
||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
@@ -738,9 +743,7 @@ jobs:
|
|||||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||||
CI=true \
|
CI=true \
|
||||||
go test -tags=integration -coverprofile=coverage.txt \
|
mage integrationtest:all -gotestflags="-coverprofile=coverage.txt"
|
||||||
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
|
|
||||||
-timeout 20m ./management/server/http/...
|
|
||||||
|
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
if: matrix.arch == 'amd64'
|
if: matrix.arch == 'amd64'
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
name: Mobile
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- "release-*"
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
android_build:
|
|
||||||
name: "Android / Build"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install Go
|
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
|
||||||
with:
|
|
||||||
go-version-file: "go.mod"
|
|
||||||
- name: Setup Android SDK
|
|
||||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
|
||||||
with:
|
|
||||||
cmdline-tools-version: 8512546
|
|
||||||
- name: Setup Java
|
|
||||||
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520
|
|
||||||
with:
|
|
||||||
java-version: "11"
|
|
||||||
distribution: "adopt"
|
|
||||||
- name: NDK Cache
|
|
||||||
id: ndk-cache
|
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
|
||||||
with:
|
|
||||||
path: /usr/local/lib/android/sdk/ndk
|
|
||||||
key: ndk-cache-23.1.7779620
|
|
||||||
- name: Setup NDK
|
|
||||||
run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620"
|
|
||||||
- name: install gomobile
|
|
||||||
run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab
|
|
||||||
- name: gomobile init
|
|
||||||
run: gomobile init
|
|
||||||
- name: build android netbird lib
|
|
||||||
run: PATH=$PATH:$(go env GOPATH) gomobile bind -o $GITHUB_WORKSPACE/netbird.aar -javapkg=io.netbird.gomobile -ldflags="-checklinkname=0 -X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/io.netbird.client/cache/wireguard -X github.com/netbirdio/netbird/version.version=buildtest" $GITHUB_WORKSPACE/client/android
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 0
|
|
||||||
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/23.1.7779620
|
|
||||||
ios_build:
|
|
||||||
name: "iOS / Build"
|
|
||||||
runs-on: macos-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install Go
|
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
|
||||||
with:
|
|
||||||
go-version-file: "go.mod"
|
|
||||||
- name: install gomobile
|
|
||||||
run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab
|
|
||||||
- name: gomobile init
|
|
||||||
run: gomobile init
|
|
||||||
- name: build iOS netbird lib
|
|
||||||
run: PATH=$PATH:$(go env GOPATH) gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=buildtest" -o ./NetBirdSDK.xcframework ./client/ios/NetBirdSDK
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 0
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
name: No New Replace Directives
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "go.mod"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-replace-directives:
|
||||||
|
name: check-replace-directives
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Compare replace directives against the base branch
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# A replace directive only applies when this module is the main
|
||||||
|
# module. Anything importing netbird as a library, the embedded
|
||||||
|
# clients among them, resolves the replaced path upstream instead and
|
||||||
|
# fails to build against whatever the replacement provides. Requiring
|
||||||
|
# a fork under its own module path avoids that; a replace does not.
|
||||||
|
#
|
||||||
|
# go.mod is parsed rather than diffed so that reordering, comments and
|
||||||
|
# single-line versus block syntax do not register as changes.
|
||||||
|
#
|
||||||
|
# Versions are part of the key because a replace can be scoped to one
|
||||||
|
# version of a module. Keyed on paths alone, retargeting such a
|
||||||
|
# directive at a different version would read as unchanged.
|
||||||
|
list_replaces() {
|
||||||
|
go mod edit -json "$1" \
|
||||||
|
| jq -r '
|
||||||
|
def ref: .Path + (if (.Version // "") == "" then "" else " " + .Version end);
|
||||||
|
(.Replace // [])[] | "\(.Old | ref) => \(.New | ref)"
|
||||||
|
' \
|
||||||
|
| sort
|
||||||
|
}
|
||||||
|
|
||||||
|
git show "${BASE_SHA}:go.mod" > /tmp/base-go.mod
|
||||||
|
list_replaces /tmp/base-go.mod > /tmp/base-replaces
|
||||||
|
list_replaces go.mod > /tmp/head-replaces
|
||||||
|
|
||||||
|
added=$(comm -13 /tmp/base-replaces /tmp/head-replaces)
|
||||||
|
if [ -n "$added" ]; then
|
||||||
|
echo "::error::This PR adds a replace directive to go.mod:"
|
||||||
|
echo "$added" | sed 's/^/ /'
|
||||||
|
echo ""
|
||||||
|
echo "A replace directive applies only to the main module, so it does not"
|
||||||
|
echo "reach anything that imports netbird as a library. Require the module"
|
||||||
|
echo "under a path you control instead, as done for github.com/netbirdio/go-nat."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
removed=$(comm -23 /tmp/base-replaces /tmp/head-replaces)
|
||||||
|
if [ -n "$removed" ]; then
|
||||||
|
echo "This PR removes replace directives:"
|
||||||
|
echo "$removed" | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
echo "No new replace directives."
|
||||||
@@ -215,7 +215,7 @@ jobs:
|
|||||||
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0
|
||||||
- name: Generate windows syso amd64
|
- name: Generate windows syso amd64
|
||||||
run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
@@ -435,7 +435,7 @@ jobs:
|
|||||||
tar -xf llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64.tar.xz
|
tar -xf llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64.tar.xz
|
||||||
echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
|
echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH
|
||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@b66839b # v1.7.0
|
||||||
- name: Install wails3 CLI
|
- name: Install wails3 CLI
|
||||||
# Version derived from go.mod so the binding generator always matches
|
# Version derived from go.mod so the binding generator always matches
|
||||||
# the wails runtime the binary links against.
|
# the wails runtime the binary links against.
|
||||||
|
|||||||
@@ -37,3 +37,16 @@ jobs:
|
|||||||
repo: netbirdio/ios-client
|
repo: netbirdio/ios-client
|
||||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||||
|
|
||||||
|
trigger_dashboard_bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||||
|
steps:
|
||||||
|
- name: Trigger dashboard wasm client bump
|
||||||
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
|
with:
|
||||||
|
workflow: bump-netbird.yml
|
||||||
|
ref: main
|
||||||
|
repo: netbirdio/dashboard
|
||||||
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
|
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ nfpms:
|
|||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
|
# Names the polkit action for the elevation prompt the app raises when an
|
||||||
|
# unprivileged user changes a privileged setting; without it the dialog
|
||||||
|
# shows a raw command line.
|
||||||
|
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
|
||||||
|
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird (>= 0.75.0)
|
- netbird (>= 0.75.0)
|
||||||
- libgtk-4-1 (>= 4.14)
|
- libgtk-4-1 (>= 4.14)
|
||||||
@@ -116,6 +121,11 @@ nfpms:
|
|||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
|
# Names the polkit action for the elevation prompt the app raises when an
|
||||||
|
# unprivileged user changes a privileged setting; without it the dialog
|
||||||
|
# shows a raw command line.
|
||||||
|
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
|
||||||
|
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird >= 0.75.0
|
- netbird >= 0.75.0
|
||||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# NetBird Agent Guidelines
|
# NetBird Agent Guidelines
|
||||||
|
|
||||||
**NetBird** is an open-source connectivity platform: a WireGuard®-based overlay
|
**NetBird** is an open source connectivity platform: a WireGuard®-based overlay
|
||||||
network with a control plane. The **agent** (`client/`) runs on user machines as
|
network with a control plane. The **agent** (`client/`) runs on user machines as
|
||||||
a privileged daemon and manages the WireGuard interface, routing, firewall, and
|
a privileged daemon and manages the WireGuard interface, routing, firewall, and
|
||||||
DNS. **Management** (`management/`) is the control plane and REST/gRPC API,
|
DNS. **Management** (`management/`) is the control plane and REST/gRPC API,
|
||||||
|
|||||||
+3
-3
@@ -192,7 +192,7 @@ dependencies are installed. Here is a short guide on how that can be done.
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
#### Go 1.25
|
#### Go 1.26
|
||||||
|
|
||||||
Follow the installation guide from https://go.dev/
|
Follow the installation guide from https://go.dev/
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ Follow the installation guide from https://go.dev/
|
|||||||
|
|
||||||
The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need:
|
The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need:
|
||||||
|
|
||||||
- Go ≥ 1.25
|
- Go ≥ 1.26
|
||||||
- Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`)
|
- Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`)
|
||||||
- The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
|
- The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
|
||||||
- The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest`
|
- The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest`
|
||||||
@@ -479,7 +479,7 @@ go test -race ./client/internal/dns/...
|
|||||||
|
|
||||||
## Checklist before submitting a PR
|
## Checklist before submitting a PR
|
||||||
|
|
||||||
As a critical network service and open-source project, we must enforce a few
|
As a critical network service and open source project, we must enforce a few
|
||||||
things before submitting a pull request. The
|
things before submitting a pull request. The
|
||||||
[pull request template](/.github/pull_request_template.md) mirrors this list —
|
[pull request template](/.github/pull_request_template.md) mirrors this list —
|
||||||
fill it in rather than deleting it.
|
fill it in rather than deleting it.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<strong>
|
<strong>
|
||||||
Start using NetBird at <a href="https://netbird.io/pricing">netbird.io</a>
|
Start using NetBird at <a href="https://netbird.io/pricing">netbird.io</a>
|
||||||
<br/>
|
<br/>
|
||||||
See <a href="https://netbird.io/docs/">Documentation</a>
|
See <a href="https://docs.netbird.io/">Documentation</a>
|
||||||
<br/>
|
<br/>
|
||||||
Join our <a href="https://docs.netbird.io/slack-url">Slack channel</a> or our <a href="https://forum.netbird.io">Community forum</a>
|
Join our <a href="https://docs.netbird.io/slack-url">Slack channel</a> or our <a href="https://forum.netbird.io">Community forum</a>
|
||||||
</strong>
|
</strong>
|
||||||
@@ -130,7 +130,7 @@ In November 2022, NetBird joined the [StartUpSecure program](https://www.forschu
|
|||||||

|

|
||||||
|
|
||||||
### Acknowledgements
|
### Acknowledgements
|
||||||
We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
|
We build on open source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
|
||||||
|
|
||||||
### Legal
|
### Legal
|
||||||
This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Report security issues one of these two ways:
|
|||||||
on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place.
|
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`.
|
- **Email** — `security@netbird.io`.
|
||||||
|
|
||||||
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open-source code, email us rather than
|
If the finding affects NetBird Cloud or our hosted infrastructure rather than the open source code, email us rather than
|
||||||
filing a repository report.
|
filing a repository report.
|
||||||
|
|
||||||
### What to include
|
### What to include
|
||||||
|
|||||||
@@ -40,6 +40,35 @@ You can then use this private endpoint to configure your AI agents, whether that
|
|||||||
Full step-by-step setup:
|
Full step-by-step setup:
|
||||||
**https://docs.netbird.io/agent-network/quickstart**
|
**https://docs.netbird.io/agent-network/quickstart**
|
||||||
|
|
||||||
|
## Client settings that don't follow the endpoint
|
||||||
|
|
||||||
|
Most of an agent's traffic follows the base URL you hand it, but a few
|
||||||
|
client-side checks call their vendor directly and never reach the proxy. On a
|
||||||
|
network that blocks direct egress they fail even though inference works, so
|
||||||
|
they are worth setting once when you roll the endpoint out.
|
||||||
|
|
||||||
|
For Claude Code:
|
||||||
|
|
||||||
|
- **Fast mode** checks availability against `api.anthropic.com` rather than the
|
||||||
|
configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the
|
||||||
|
agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when
|
||||||
|
the proxy injects the real provider key) or when a TLS-inspecting proxy
|
||||||
|
answers the check itself. Set
|
||||||
|
`CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the
|
||||||
|
connection outright. Fast mode is an Anthropic-API feature, so it is
|
||||||
|
unavailable on a Bedrock- or Vertex-backed endpoint whatever you set.
|
||||||
|
- **Model discovery** is off by default. Set
|
||||||
|
`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the
|
||||||
|
models your policies authorise; the proxy filters the response to that set.
|
||||||
|
The client gives discovery a three-second budget and treats any redirect as
|
||||||
|
a failure, so the endpoint must serve `/v1/models` directly.
|
||||||
|
- **The WebFetch domain safety check** also calls `api.anthropic.com` directly
|
||||||
|
and is unaffected by the variables above.
|
||||||
|
|
||||||
|
Allowing direct egress to `api.anthropic.com` covers the network cases but not
|
||||||
|
the credential one, where the check reaches Anthropic and is rejected because
|
||||||
|
the agent presents a proxy-issued key.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Agent Network is built on two existing NetBird capabilities:
|
Agent Network is built on two existing NetBird capabilities:
|
||||||
@@ -67,6 +96,42 @@ components:
|
|||||||
— the management-side control plane: providers, policies, guardrails, limits, routing,
|
— the management-side control plane: providers, policies, guardrails, limits, routing,
|
||||||
and usage/access logs.
|
and usage/access logs.
|
||||||
|
|
||||||
|
## Access roles
|
||||||
|
|
||||||
|
Agent Network permissions build on the account permission matrix
|
||||||
|
([`management/server/permissions/`](../management/server/permissions)). The
|
||||||
|
`agent_network` area is split into dotted submodules (`agent_network.providers`,
|
||||||
|
`.policies`, `.guardrails`, `.budgets`, `.usage`, `.logs`, `.settings`); a role may
|
||||||
|
grant a single submodule or the parent, which cascades to all of them.
|
||||||
|
|
||||||
|
Two roles delegate Agent Network access without account-admin rights:
|
||||||
|
|
||||||
|
- **`agent_network_admin`** — full control over the whole `agent_network` area plus
|
||||||
|
read-only users, groups, peers, and account info (needed to build policies).
|
||||||
|
Nothing else in the account.
|
||||||
|
- **`usage_viewer`** — the regular User baseline plus read on
|
||||||
|
`agent_network.usage` (the aggregated usage and cost overview) and read-only
|
||||||
|
access to the resources the usage filters resolve against: users, groups,
|
||||||
|
peers, and the provider list (connection config redacted — no upstream URLs
|
||||||
|
or operator-supplied header values). No policies, and no account-wide
|
||||||
|
request-level access logs; like any caller, it still reads its own requests
|
||||||
|
through the self-scoped endpoints below.
|
||||||
|
|
||||||
|
Every authenticated user, regardless of role, can read the caller-scoped
|
||||||
|
self-service endpoint `GET /api/agent-network/agent-config` (the endpoint, providers,
|
||||||
|
and models the caller's own policies allow — what a local AI tool needs and nothing
|
||||||
|
more). The regular usage and access-log endpoints self-scope instead of denying:
|
||||||
|
a caller without the account-wide grant gets their own rows back, so "my usage"
|
||||||
|
and "my requests" are the same endpoints the admin dashboard uses. The provider
|
||||||
|
list self-scopes the same way — a caller without the providers grant gets the
|
||||||
|
providers their own policies authorize, reduced to the display surface, with
|
||||||
|
each provider's model list cut to what the caller's policy guardrails and the
|
||||||
|
provider's declared models effectively permit (the same computation the setup
|
||||||
|
answer and the proxy use). This feeds the dashboard's provider and model
|
||||||
|
filters. Role
|
||||||
|
definitions live in
|
||||||
|
[`management/server/permissions/roles/`](../management/server/permissions/roles).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Full documentation, architecture, and quickstart:
|
Full documentation, architecture, and quickstart:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||||
"github.com/netbirdio/netbird/client/net"
|
"github.com/netbirdio/netbird/client/net"
|
||||||
|
"github.com/netbirdio/netbird/client/netevents"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
"github.com/netbirdio/netbird/formatter"
|
"github.com/netbirdio/netbird/formatter"
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
@@ -40,11 +41,6 @@ const (
|
|||||||
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConnectionListener export internal Listener for mobile
|
|
||||||
type ConnectionListener interface {
|
|
||||||
peer.Listener
|
|
||||||
}
|
|
||||||
|
|
||||||
// TunAdapter export internal TunAdapter for mobile
|
// TunAdapter export internal TunAdapter for mobile
|
||||||
type TunAdapter interface {
|
type TunAdapter interface {
|
||||||
device.TunAdapter
|
device.TunAdapter
|
||||||
@@ -85,6 +81,10 @@ type Client struct {
|
|||||||
deviceName string
|
deviceName string
|
||||||
uiVersion string
|
uiVersion string
|
||||||
networkChangeListener listener.NetworkChangeListener
|
networkChangeListener listener.NetworkChangeListener
|
||||||
|
// netMgr outlives engine restarts: it mirrors the OS connectivity, not
|
||||||
|
// the engine lifecycle. Run and RunWithoutLogin inject its state and
|
||||||
|
// sweeper into each new ConnectClient.
|
||||||
|
netMgr *netevents.Manager
|
||||||
|
|
||||||
stateMu sync.RWMutex
|
stateMu sync.RWMutex
|
||||||
connectClient *internal.ConnectClient
|
connectClient *internal.ConnectClient
|
||||||
@@ -148,14 +148,17 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
|
|||||||
execWorkaround(androidSDKVersion)
|
execWorkaround(androidSDKVersion)
|
||||||
|
|
||||||
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
|
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
|
||||||
|
system.SetIFaceDiscover(iFaceDiscover)
|
||||||
|
recorder := peer.NewRecorder("")
|
||||||
return &Client{
|
return &Client{
|
||||||
deviceName: deviceName,
|
deviceName: deviceName,
|
||||||
uiVersion: uiVersion,
|
uiVersion: uiVersion,
|
||||||
tunAdapter: tunAdapter,
|
tunAdapter: tunAdapter,
|
||||||
iFaceDiscover: iFaceDiscover,
|
iFaceDiscover: iFaceDiscover,
|
||||||
recorder: peer.NewRecorder(""),
|
recorder: recorder,
|
||||||
ctxCancelLock: &sync.Mutex{},
|
ctxCancelLock: &sync.Mutex{},
|
||||||
networkChangeListener: networkChangeListener,
|
networkChangeListener: networkChangeListener,
|
||||||
|
netMgr: netevents.NewManager(recorder),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +199,9 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
|||||||
}
|
}
|
||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
|
||||||
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||||
|
internal.WithNetEvents(c.netMgr))
|
||||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||||
// is authenticated again — release the latch Status() reports from. Clear
|
// is authenticated again — release the latch Status() reports from. Clear
|
||||||
@@ -237,7 +242,8 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
|||||||
|
|
||||||
// todo do not throw error in case of cancelled context
|
// todo do not throw error in case of cancelled context
|
||||||
ctx = internal.CtxInitState(ctx)
|
ctx = internal.CtxInitState(ctx)
|
||||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||||
|
internal.WithNetEvents(c.netMgr))
|
||||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||||
}
|
}
|
||||||
@@ -285,6 +291,26 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNetworkAvailable feeds OS-reported network availability into the client.
|
||||||
|
// While unavailable, the internal reconnect loops suspend their attempts and
|
||||||
|
// the connection listener reports NoNetwork instead of Connecting; when
|
||||||
|
// availability returns, the loops resume immediately with a fresh backoff.
|
||||||
|
// Losing the last network also sweeps the registered connections: nothing can
|
||||||
|
// redial while offline, so the stale sockets would otherwise stay silently
|
||||||
|
// "connected" until their own timeouts and the client would keep reporting
|
||||||
|
// Connected with no network at all.
|
||||||
|
func (c *Client) SetNetworkAvailable(available bool) {
|
||||||
|
c.netMgr.SetNetworkAvailable(available)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyNetworkChange marks the management, signal and relay connections
|
||||||
|
// stale after the OS switched networks and schedules a sweep that cuts
|
||||||
|
// whatever has not redialed on the new network by then. The engine and the
|
||||||
|
// TUN device stay untouched.
|
||||||
|
func (c *Client) NotifyNetworkChange() {
|
||||||
|
c.netMgr.NotifyNetworkChange()
|
||||||
|
}
|
||||||
|
|
||||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||||
// It works both with and without a running engine. anonymizeLevel is "default"
|
// It works both with and without a running engine. anonymizeLevel is "default"
|
||||||
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
// or "strict"; strict also anonymizes internal IP ranges, peer names, and
|
||||||
@@ -525,7 +551,11 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
|
|||||||
|
|
||||||
// SetConnectionListener set the network connection listener
|
// SetConnectionListener set the network connection listener
|
||||||
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
||||||
c.recorder.SetConnectionListener(listener)
|
if listener == nil {
|
||||||
|
c.recorder.RemoveConnectionListener()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.recorder.SetConnectionListener(connectionListenerAdapter{listener})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveConnectionListener remove connection listener
|
// RemoveConnectionListener remove connection listener
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client state values delivered via ConnectionListener.OnStateChanged,
|
||||||
|
// re-exported as basic constants so gomobile emits them into the generated
|
||||||
|
// Java bindings. They mirror peer.ClientState*: append-only, never reorder.
|
||||||
|
const (
|
||||||
|
ClientStateDisconnected = int(peer.ClientStateDisconnected)
|
||||||
|
ClientStateConnected = int(peer.ClientStateConnected)
|
||||||
|
ClientStateConnecting = int(peer.ClientStateConnecting)
|
||||||
|
ClientStateDisconnecting = int(peer.ClientStateDisconnecting)
|
||||||
|
ClientStateNoNetwork = int(peer.ClientStateNoNetwork)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionListener export internal Listener for mobile. It mirrors
|
||||||
|
// peer.Listener with OnStateChanged taking a plain int (one of the
|
||||||
|
// ClientState* constants), because gomobile cannot bind named types.
|
||||||
|
type ConnectionListener interface {
|
||||||
|
OnStateChanged(state int)
|
||||||
|
OnConnected()
|
||||||
|
OnDisconnected()
|
||||||
|
OnConnecting()
|
||||||
|
OnDisconnecting()
|
||||||
|
OnAddressChanged(string, string)
|
||||||
|
OnPeersListChanged(int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to
|
||||||
|
// peer.Listener, converting the typed state to the int the binding carries.
|
||||||
|
type connectionListenerAdapter struct {
|
||||||
|
ConnectionListener
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) {
|
||||||
|
a.ConnectionListener.OnStateChanged(int(state))
|
||||||
|
}
|
||||||
+35
-24
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/mobile"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -181,7 +182,7 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
|||||||
// Stored after Login, not before: a rejected token must not leave a hint
|
// Stored after Login, not before: a rejected token must not leave a hint
|
||||||
// pointing at an account that cannot be used.
|
// pointing at an account that cannot be used.
|
||||||
if email != "" && a.cfgPath != "" {
|
if email != "" && a.cfgPath != "" {
|
||||||
if err := writeProfileEmail(a.cfgPath, email); err != nil {
|
if err := mobile.WriteProfileEmail(a.cfgPath, email); err != nil {
|
||||||
log.Warnf("failed to store profile account email: %v", err)
|
log.Warnf("failed to store profile account email: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,39 +192,49 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginHintSetter is implemented by both concrete flows (PKCE and device code)
|
|
||||||
// but absent from the OAuthFlow interface, hence the assertion below — the same
|
|
||||||
// way internal/auth wires it in authenticateWithPKCEFlow.
|
|
||||||
type loginHintSetter interface {
|
|
||||||
SetLoginHint(hint string)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
|
||||||
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
|
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
|
return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil)
|
||||||
// leaves the choice to the IdP, which is how accounts get switched.
|
}
|
||||||
if a.cfgPath != "" {
|
|
||||||
if hint := readProfileEmail(a.cfgPath); hint != "" {
|
// profileLoginHint returns the stored account email for the profile at cfgPath.
|
||||||
if setter, ok := oAuthFlow.(loginHintSetter); ok {
|
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
|
||||||
setter.SetLoginHint(hint)
|
// choice to the IdP. Switching accounts is done by switching or removing
|
||||||
}
|
// profiles, not by logging out — logout keeps the email.
|
||||||
}
|
func profileLoginHint(cfgPath string) string {
|
||||||
|
if cfgPath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return mobile.ReadProfileEmail(cfgPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOAuthFlow drives an already acquired OAuth flow to a token: requests the
|
||||||
|
// flow info, presents the verification URL through the opener and waits for
|
||||||
|
// the browser round-trip. Open is called synchronously — it is what marks the
|
||||||
|
// surface as opened on the client side, and a fast token's OnLoginSuccess is
|
||||||
|
// a no-op until it has, so the dismissal would be dropped rather than
|
||||||
|
// delayed. Openers must therefore not block: they post their UI work and
|
||||||
|
// return. onWaiting, when set, runs after the URL is shown, right before the
|
||||||
|
// blocking wait.
|
||||||
|
func runOAuthFlow(ctx context.Context, flow auth.OAuthFlow, urlOpener URLOpener, onWaiting func()) (*auth.TokenInfo, error) {
|
||||||
|
flowInfo, err := flow.RequestAuthInfo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request auth info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
|
urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
|
if onWaiting != nil {
|
||||||
|
onWaiting()
|
||||||
}
|
}
|
||||||
|
|
||||||
go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode)
|
tokenInfo, err := flow.WaitToken(ctx, flowInfo)
|
||||||
|
|
||||||
tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("waiting for browser login failed: %v", err)
|
return nil, fmt.Errorf("wait for token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &tokenInfo, nil
|
return &tokenInfo, nil
|
||||||
|
|||||||
@@ -3,41 +3,37 @@
|
|||||||
package android
|
package android
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"github.com/netbirdio/netbird/client/mobile"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
// Android uses a single user context per app.
|
||||||
androidUsername = "android"
|
androidUsername = "android"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Profile represents a profile for gomobile
|
// Profile represents a profile for gomobile.
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
// Email is the account this profile last logged in with, "" if it never
|
// Email is the account this profile last logged in with, "" if it never
|
||||||
// completed an SSO login or was logged out. See profile_state.go.
|
// completed an SSO login. Kept across logouts; cleared when the profile is
|
||||||
|
// removed. See client/mobile/profile_state.go.
|
||||||
Email string
|
Email string
|
||||||
IsActive bool
|
IsActive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfileArray wraps profiles for gomobile compatibility
|
// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot
|
||||||
|
// bind Go slices directly).
|
||||||
type ProfileArray struct {
|
type ProfileArray struct {
|
||||||
items []*Profile
|
items []*Profile
|
||||||
}
|
}
|
||||||
|
|
||||||
// Length returns the number of profiles
|
// Length returns the number of profiles.
|
||||||
func (p *ProfileArray) Length() int {
|
func (p *ProfileArray) Length() int {
|
||||||
return len(p.items)
|
return len(p.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the profile at index i
|
// Get returns the profile at index i, or nil if out of range.
|
||||||
func (p *ProfileArray) Get(i int) *Profile {
|
func (p *ProfileArray) Get(i int) *Profile {
|
||||||
if i < 0 || i >= len(p.items) {
|
if i < 0 || i >= len(p.items) {
|
||||||
return nil
|
return nil
|
||||||
@@ -45,248 +41,98 @@ func (p *ProfileArray) Get(i int) *Profile {
|
|||||||
return p.items[i]
|
return p.items[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
// ProfileManager adapts the shared mobile profile manager (client/mobile) to
|
||||||
|
// gomobile-friendly types. See that package for the on-disk layout and
|
||||||
/data/data/io.netbird.client/files/ ← configDir parameter
|
// semantics.
|
||||||
├── netbird.cfg ← Default profile config
|
|
||||||
├── state.json ← Default profile state
|
|
||||||
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
|
|
||||||
└── profiles/ ← Subdirectory for non-default profiles
|
|
||||||
├── work.json ← Legacy work profile config
|
|
||||||
├── work.state.json ← Legacy work profile state
|
|
||||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
|
|
||||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ProfileManager manages profiles for Android
|
|
||||||
// It wraps the internal profilemanager to provide Android-specific behavior
|
|
||||||
type ProfileManager struct {
|
type ProfileManager struct {
|
||||||
configDir string
|
impl *mobile.ProfileManager
|
||||||
serviceMgr *profilemanager.ServiceManager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProfileManager creates a new profile manager for Android
|
// NewProfileManager creates a new profile manager for Android. configDir is
|
||||||
|
// the app's files directory.
|
||||||
func NewProfileManager(configDir string) *ProfileManager {
|
func NewProfileManager(configDir string) *ProfileManager {
|
||||||
// Set the default config path for Android (stored in root configDir, not profiles/)
|
return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)}
|
||||||
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
|
|
||||||
|
|
||||||
// Set global paths for Android
|
|
||||||
profilemanager.DefaultConfigPathDir = configDir
|
|
||||||
profilemanager.DefaultConfigPath = defaultConfigPath
|
|
||||||
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
|
|
||||||
|
|
||||||
// Create ServiceManager with profiles/ subdirectory
|
|
||||||
// This avoids modifying the global ConfigDirOverride for profile listing
|
|
||||||
profilesDir := filepath.Join(configDir, profilesSubdir)
|
|
||||||
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
|
|
||||||
|
|
||||||
return &ProfileManager{
|
|
||||||
configDir: configDir,
|
|
||||||
serviceMgr: serviceMgr,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListProfiles returns all available profiles
|
// ListProfiles returns all available profiles, including the default profile,
|
||||||
|
// with their active status set.
|
||||||
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||||
// Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive)
|
profiles, err := pm.impl.ListProfiles()
|
||||||
internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to list profiles: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert internal profiles to Android Profile type
|
items := make([]*Profile, 0, len(profiles))
|
||||||
var profiles []*Profile
|
for i := range profiles {
|
||||||
for _, p := range internalProfiles {
|
items = append(items, fromMobileProfile(&profiles[i]))
|
||||||
profiles = append(profiles, &Profile{
|
|
||||||
ID: p.ID.String(),
|
|
||||||
Name: p.Name,
|
|
||||||
Email: pm.profileEmail(p.ID.String()),
|
|
||||||
IsActive: p.IsActive,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return &ProfileArray{items: items}, nil
|
||||||
return &ProfileArray{items: profiles}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveProfile returns the currently active profile name
|
// GetActiveProfile returns the currently active profile.
|
||||||
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||||
// Use ServiceManager to stay consistent with ListProfiles
|
p, err := pm.impl.GetActiveProfile()
|
||||||
// ServiceManager uses active_profile.json
|
|
||||||
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get active profile: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return fromMobileProfile(p), nil
|
||||||
// ActiveProfileState only stores the ID (and username), not the display
|
|
||||||
// name. Resolve the ID to the full profile so callers get the real Name.
|
|
||||||
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
|
||||||
}
|
|
||||||
return &Profile{
|
|
||||||
ID: prof.ID.String(),
|
|
||||||
Name: prof.Name,
|
|
||||||
Email: pm.profileEmail(prof.ID.String()),
|
|
||||||
IsActive: true,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// profileEmail returns the account email recorded for a profile. Display-only, so
|
// SwitchProfile records the given profile ID as the active profile. The caller
|
||||||
// an unresolvable path degrades to "" rather than an error.
|
// must stop the VPN tunnel before switching.
|
||||||
func (pm *ProfileManager) profileEmail(id string) string {
|
|
||||||
configPath, err := pm.getProfileConfigPath(id)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return readProfileEmail(configPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SwitchProfile switches to a different profile
|
|
||||||
func (pm *ProfileManager) SwitchProfile(id string) error {
|
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||||
// Use ServiceManager to stay consistent with ListProfiles
|
return pm.impl.SwitchProfile(id)
|
||||||
// ServiceManager uses active_profile.json
|
|
||||||
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
|
||||||
ID: profilemanager.ID(id),
|
|
||||||
Username: androidUsername,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to switch profile: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("switched to profile: %s", id)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddProfile creates a new profile
|
// AddProfile creates a new profile with the given display name and a
|
||||||
|
// generated ID.
|
||||||
func (pm *ProfileManager) AddProfile(profileName string) error {
|
func (pm *ProfileManager) AddProfile(profileName string) error {
|
||||||
// Use ServiceManager (creates profile in profiles/ directory)
|
_, err := pm.impl.AddProfile(profileName)
|
||||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
|
return err
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to add profile: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("created new profile: %s", profile.ID)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogoutProfile logs out from a profile (clears authentication)
|
// RenameProfile changes the display name of the profile identified by id. The
|
||||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
// on-disk filename (the ID) is left unchanged.
|
||||||
configPath, err := pm.getProfileConfigPath(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
|
||||||
return fmt.Errorf("id '%s' is not valid", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if profile exists
|
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("profile '%s' does not exist", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read current config using internal profilemanager
|
|
||||||
config, err := profilemanager.ReadConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read profile config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear authentication by removing private key and SSH key
|
|
||||||
config.PrivateKey = ""
|
|
||||||
config.SSHKey = ""
|
|
||||||
|
|
||||||
// Save config using internal profilemanager
|
|
||||||
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
|
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not fatal: a stale hint costs an account switch, not the logout itself.
|
|
||||||
if err := removeProfileEmail(configPath); err != nil {
|
|
||||||
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("logged out from profile: %s", id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||||
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil {
|
return pm.impl.RenameProfile(id, newName)
|
||||||
return fmt.Errorf("failed to rename profile: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("renamed profile %s to: %s", id, newName)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveProfile deletes a profile
|
// LogoutProfile clears authentication data for a profile, forcing a re-login.
|
||||||
|
// The management URL and other settings are preserved.
|
||||||
|
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||||
|
return pm.impl.LogoutProfile(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveProfile deletes a profile. The default profile and the active profile
|
||||||
|
// cannot be removed.
|
||||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||||
// Use ServiceManager (removes profile from profiles/ directory)
|
return pm.impl.RemoveProfile(id)
|
||||||
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
|
|
||||||
return fmt.Errorf("failed to remove profile: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("removed profile: %s", id)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getProfileConfigPath returns the config file path for a profile
|
// GetConfigPath returns the config file path for the given profile ID. Java
|
||||||
// This is needed for Android-specific path handling (netbird.cfg for default profile)
|
// should call this instead of constructing paths with Preferences.configFile().
|
||||||
func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
|
|
||||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
|
||||||
return "", fmt.Errorf("id %q is not valid", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if id == profilemanager.DefaultProfileName {
|
|
||||||
// Android uses netbird.cfg for default profile instead of default.json
|
|
||||||
// Default profile is stored in root configDir, not in profiles/
|
|
||||||
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
|
||||||
return filepath.Join(profilesDir, id+".json"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConfigPath returns the config file path for a given profile id
|
|
||||||
// Java should call this instead of constructing paths with Preferences.configFile()
|
|
||||||
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
|
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
|
||||||
return pm.getProfileConfigPath(id)
|
return pm.impl.GetConfigPath(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStateFilePath returns the state file path for a given profile
|
// GetStateFilePath returns the state file path for the given profile ID. Java
|
||||||
// Java should call this instead of constructing paths with Preferences.stateFile()
|
// should call this instead of constructing paths with Preferences.stateFile().
|
||||||
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
||||||
if id == "" || id == profilemanager.DefaultProfileName {
|
return pm.impl.GetStateFilePath(id)
|
||||||
return filepath.Join(pm.configDir, "state.json"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
|
||||||
return "", fmt.Errorf("id %q is not valid", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
|
||||||
return filepath.Join(profilesDir, id+".state.json"), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveConfigPath returns the config file path for the currently active profile
|
// GetActiveConfigPath returns the config file path for the currently active
|
||||||
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile()
|
// profile.
|
||||||
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
||||||
activeProfile, err := pm.GetActiveProfile()
|
return pm.impl.GetActiveConfigPath()
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
|
||||||
}
|
|
||||||
return pm.GetConfigPath(activeProfile.ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveStateFilePath returns the state file path for the currently active profile
|
// GetActiveStateFilePath returns the state file path for the currently active
|
||||||
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile()
|
// profile.
|
||||||
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
||||||
activeProfile, err := pm.GetActiveProfile()
|
return pm.impl.GetActiveStateFilePath()
|
||||||
if err != nil {
|
}
|
||||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
|
||||||
}
|
func fromMobileProfile(p *mobile.Profile) *Profile {
|
||||||
return pm.GetStateFilePath(activeProfile.ID)
|
return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
)
|
||||||
|
|
||||||
|
type prefsStore interface {
|
||||||
|
Get(namespace string, v any) (bool, error)
|
||||||
|
Put(namespace string, v any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type profilePrefs struct {
|
||||||
|
prefs *profilemanager.Prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) {
|
||||||
|
if configDir == "" || profileID == "" {
|
||||||
|
return nil, fmt.Errorf("profile prefs require a config dir and profile ID")
|
||||||
|
}
|
||||||
|
prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &profilePrefs{prefs: prefs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *profilePrefs) Get(namespace string, v any) (bool, error) {
|
||||||
|
return p.prefs.Get(namespace, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *profilePrefs) Put(namespace string, v any) error {
|
||||||
|
return p.prefs.Put(namespace, v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
// SplitTunnelMode is which of the two selections, if either, the tunnel applies.
|
||||||
|
// Its values land in the profile's stored preferences, so the constants below
|
||||||
|
// are append-only and must never be reordered.
|
||||||
|
type SplitTunnelMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
modeOff SplitTunnelMode = iota
|
||||||
|
modeExclude
|
||||||
|
modeInclude
|
||||||
|
)
|
||||||
|
|
||||||
|
// The same modes as basic ints. gomobile drops a constant whose type is not a
|
||||||
|
// basic one, so these are what reaches the generated Java bindings, and they
|
||||||
|
// keep the Android side tied to the values above instead of repeating 0, 1, 2.
|
||||||
|
const (
|
||||||
|
SplitTunnelModeOff = int(modeOff)
|
||||||
|
SplitTunnelModeExclude = int(modeExclude)
|
||||||
|
SplitTunnelModeInclude = int(modeInclude)
|
||||||
|
)
|
||||||
|
|
||||||
|
type splitTunnelSection struct {
|
||||||
|
Mode SplitTunnelMode `json:"mode"`
|
||||||
|
Excluded []string `json:"excluded"`
|
||||||
|
Included []string `json:"included"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageList wraps []string for gomobile compatibility.
|
||||||
|
type PackageList struct {
|
||||||
|
items []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPackageList creates an empty list to fill via Add.
|
||||||
|
func NewPackageList() *PackageList {
|
||||||
|
return &PackageList{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add appends a package name, ignoring empty ones.
|
||||||
|
func (l *PackageList) Add(s string) {
|
||||||
|
if s == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.items = append(l.items, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the number of entries.
|
||||||
|
func (l *PackageList) Size() int {
|
||||||
|
return len(l.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the entry at index i, or an empty string when out of range.
|
||||||
|
func (l *PackageList) Get(i int) string {
|
||||||
|
if i < 0 || i >= len(l.items) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.items[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitTunnelSettings is one profile's choice of which applications the tunnel
|
||||||
|
// carries. The two selections are kept apart because the platform applies one
|
||||||
|
// or the other and never both, and so that switching mode does not throw away
|
||||||
|
// the picks made in the other one.
|
||||||
|
//
|
||||||
|
// Mode is an int rather than a SplitTunnelMode because gomobile carries only
|
||||||
|
// basic types across the binding. It holds one of the SplitTunnelMode*
|
||||||
|
// constants.
|
||||||
|
type SplitTunnelSettings struct {
|
||||||
|
Mode int
|
||||||
|
Excluded *PackageList
|
||||||
|
Included *PackageList
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSplitTunnelSettings creates settings that carry every application.
|
||||||
|
func NewSplitTunnelSettings() *SplitTunnelSettings {
|
||||||
|
return &SplitTunnelSettings{
|
||||||
|
Mode: SplitTunnelModeOff,
|
||||||
|
Excluded: NewPackageList(),
|
||||||
|
Included: NewPackageList(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func packagesOf(list *PackageList) []string {
|
||||||
|
if list == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(list.items))
|
||||||
|
out = append(out, list.items...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeSplitTunnelMode maps anything outside the known set to off, so a mode
|
||||||
|
// written by a newer build degrades to carrying every application rather than to
|
||||||
|
// some other mode's behaviour.
|
||||||
|
func normalizeSplitTunnelMode(mode SplitTunnelMode) SplitTunnelMode {
|
||||||
|
switch mode {
|
||||||
|
case modeExclude, modeInclude:
|
||||||
|
return mode
|
||||||
|
default:
|
||||||
|
return modeOff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsFromSection(section splitTunnelSection) *SplitTunnelSettings {
|
||||||
|
out := NewSplitTunnelSettings()
|
||||||
|
out.Mode = int(normalizeSplitTunnelMode(section.Mode))
|
||||||
|
for _, pkg := range section.Excluded {
|
||||||
|
out.Excluded.Add(pkg)
|
||||||
|
}
|
||||||
|
for _, pkg := range section.Included {
|
||||||
|
out.Included.Add(pkg)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionFromSettings(settings *SplitTunnelSettings) splitTunnelSection {
|
||||||
|
if settings == nil {
|
||||||
|
settings = NewSplitTunnelSettings()
|
||||||
|
}
|
||||||
|
return splitTunnelSection{
|
||||||
|
Mode: normalizeSplitTunnelMode(SplitTunnelMode(settings.Mode)),
|
||||||
|
Excluded: packagesOf(settings.Excluded),
|
||||||
|
Included: packagesOf(settings.Included),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
const splitTunnelNamespace = "split-tunnel"
|
||||||
|
|
||||||
|
// SplitTunnelStore reads and writes a profile's split tunnelling settings.
|
||||||
|
type SplitTunnelStore struct {
|
||||||
|
prefs prefsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSplitTunnelStore opens the split tunnelling store of the given profile.
|
||||||
|
func NewSplitTunnelStore(configDir, profileID string) (*SplitTunnelStore, error) {
|
||||||
|
prefs, err := newProfilePrefs(configDir, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SplitTunnelStore{prefs: prefs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load returns the stored settings, or settings that carry every application
|
||||||
|
// when the profile has none saved.
|
||||||
|
func (s *SplitTunnelStore) Load() (*SplitTunnelSettings, error) {
|
||||||
|
var section splitTunnelSection
|
||||||
|
if _, err := s.prefs.Get(splitTunnelNamespace, §ion); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return settingsFromSection(section), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save replaces the stored settings.
|
||||||
|
func (s *SplitTunnelStore) Save(settings *SplitTunnelSettings) error {
|
||||||
|
return s.prefs.Put(splitTunnelNamespace, sectionFromSettings(settings))
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeSplitTunnelMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mode SplitTunnelMode
|
||||||
|
want SplitTunnelMode
|
||||||
|
}{
|
||||||
|
{name: "exclude is kept", mode: modeExclude, want: modeExclude},
|
||||||
|
{name: "include is kept", mode: modeInclude, want: modeInclude},
|
||||||
|
{name: "off is kept", mode: modeOff, want: modeOff},
|
||||||
|
{name: "a mode from a newer build falls back to off", mode: SplitTunnelMode(7), want: modeOff},
|
||||||
|
{name: "a negative mode falls back to off", mode: SplitTunnelMode(-1), want: modeOff},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := normalizeSplitTunnelMode(tt.mode); got != tt.want {
|
||||||
|
t.Errorf("normalizeSplitTunnelMode(%d) = %d, want %d", tt.mode, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The constants the Android side reads must stay the values the store writes:
|
||||||
|
// gomobile carries the ints below, not the typed constants they mirror.
|
||||||
|
func TestSplitTunnelModeConstantsMirrorTheTypedOnes(t *testing.T) {
|
||||||
|
if SplitTunnelModeOff != int(modeOff) {
|
||||||
|
t.Errorf("off = %d, want %d", SplitTunnelModeOff, modeOff)
|
||||||
|
}
|
||||||
|
if SplitTunnelModeExclude != int(modeExclude) {
|
||||||
|
t.Errorf("exclude = %d, want %d", SplitTunnelModeExclude, modeExclude)
|
||||||
|
}
|
||||||
|
if SplitTunnelModeInclude != int(modeInclude) {
|
||||||
|
t.Errorf("include = %d, want %d", SplitTunnelModeInclude, modeInclude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsFromSection(t *testing.T) {
|
||||||
|
got := settingsFromSection(splitTunnelSection{
|
||||||
|
Mode: modeExclude,
|
||||||
|
Excluded: []string{"com.example.a", "com.example.b"},
|
||||||
|
Included: []string{"com.example.c"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if got.Mode != SplitTunnelModeExclude {
|
||||||
|
t.Errorf("mode = %d, want %d", got.Mode, SplitTunnelModeExclude)
|
||||||
|
}
|
||||||
|
if got.Excluded.Size() != 2 || got.Excluded.Get(0) != "com.example.a" {
|
||||||
|
t.Errorf("excluded = %v, want the two stored packages", packagesOf(got.Excluded))
|
||||||
|
}
|
||||||
|
if got.Included.Size() != 1 || got.Included.Get(0) != "com.example.c" {
|
||||||
|
t.Errorf("included = %v, want the stored package", packagesOf(got.Included))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A profile that has never stored anything decodes into an empty section, and
|
||||||
|
// must come back as settings that carry every application rather than as nil
|
||||||
|
// lists the caller would have to guard against.
|
||||||
|
func TestSettingsFromEmptySectionCarriesEverything(t *testing.T) {
|
||||||
|
got := settingsFromSection(splitTunnelSection{})
|
||||||
|
|
||||||
|
if got.Mode != SplitTunnelModeOff {
|
||||||
|
t.Errorf("mode = %d, want %d", got.Mode, SplitTunnelModeOff)
|
||||||
|
}
|
||||||
|
if got.Excluded == nil || got.Included == nil {
|
||||||
|
t.Fatal("both selections must be usable lists, not nil")
|
||||||
|
}
|
||||||
|
if got.Excluded.Size() != 0 || got.Included.Size() != 0 {
|
||||||
|
t.Errorf("selections = %v/%v, want both empty", packagesOf(got.Excluded), packagesOf(got.Included))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The section is what the profile's preference file holds, so the mode has to
|
||||||
|
// survive a JSON round trip as the number the constants name.
|
||||||
|
func TestSectionEncodesTheModeAsItsNumber(t *testing.T) {
|
||||||
|
raw, err := json.Marshal(sectionFromSettings(&SplitTunnelSettings{Mode: SplitTunnelModeInclude}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal section: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var back splitTunnelSection
|
||||||
|
if err := json.Unmarshal(raw, &back); err != nil {
|
||||||
|
t.Fatalf("unmarshal section: %v", err)
|
||||||
|
}
|
||||||
|
if back.Mode != modeInclude {
|
||||||
|
t.Errorf("mode = %d, want %d, from %s", back.Mode, modeInclude, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSectionFromSettingsRoundTrip(t *testing.T) {
|
||||||
|
settings := NewSplitTunnelSettings()
|
||||||
|
settings.Mode = SplitTunnelModeInclude
|
||||||
|
settings.Included.Add("com.example.a")
|
||||||
|
settings.Excluded.Add("com.example.b")
|
||||||
|
|
||||||
|
section := sectionFromSettings(settings)
|
||||||
|
back := settingsFromSection(section)
|
||||||
|
|
||||||
|
if back.Mode != SplitTunnelModeInclude {
|
||||||
|
t.Errorf("mode = %d, want %d", back.Mode, SplitTunnelModeInclude)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(packagesOf(back.Included), []string{"com.example.a"}) {
|
||||||
|
t.Errorf("included = %v, want [com.example.a]", packagesOf(back.Included))
|
||||||
|
}
|
||||||
|
// The inactive selection survives, so switching mode back does not make the
|
||||||
|
// user pick their applications again.
|
||||||
|
if !reflect.DeepEqual(packagesOf(back.Excluded), []string{"com.example.b"}) {
|
||||||
|
t.Errorf("excluded = %v, want [com.example.b]", packagesOf(back.Excluded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mode the Java side never sets, such as one left by a newer build, must not
|
||||||
|
// reach the stored section either.
|
||||||
|
func TestSectionFromSettingsNormalizesAnUnknownMode(t *testing.T) {
|
||||||
|
section := sectionFromSettings(&SplitTunnelSettings{Mode: 7})
|
||||||
|
|
||||||
|
if section.Mode != modeOff {
|
||||||
|
t.Errorf("mode = %d, want %d", section.Mode, modeOff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSectionFromNilSettings(t *testing.T) {
|
||||||
|
section := sectionFromSettings(nil)
|
||||||
|
|
||||||
|
if section.Mode != modeOff {
|
||||||
|
t.Errorf("mode = %d, want %d", section.Mode, modeOff)
|
||||||
|
}
|
||||||
|
if len(section.Excluded) != 0 || len(section.Included) != 0 {
|
||||||
|
t.Errorf("selections = %v/%v, want both empty", section.Excluded, section.Included)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageListIgnoresEmptyAndBounds(t *testing.T) {
|
||||||
|
list := NewPackageList()
|
||||||
|
list.Add("com.example.a")
|
||||||
|
list.Add("")
|
||||||
|
|
||||||
|
if list.Size() != 1 {
|
||||||
|
t.Errorf("size = %d, want 1", list.Size())
|
||||||
|
}
|
||||||
|
if list.Get(-1) != "" || list.Get(5) != "" {
|
||||||
|
t.Error("out of range access must return an empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||||
|
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sshDialTimeout = 30 * time.Second
|
||||||
|
sshDetectionTimeout = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordRequiredMarker tells Java to prompt for a password and retry. It is
|
||||||
|
// a string because gomobile flattens errors to their message, so a sentinel
|
||||||
|
// value would not survive the binding.
|
||||||
|
const PasswordRequiredMarker = "netbird-ssh-password-required"
|
||||||
|
|
||||||
|
// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation,
|
||||||
|
// retry with TrustHostKey set. The presented fingerprint is appended after the
|
||||||
|
// marker so the prompt can display it and the retry can guard against a key
|
||||||
|
// that changed between the two connects. Only regular (non-NetBird) servers
|
||||||
|
// reach this: NetBird peers verify against the registry.
|
||||||
|
const HostKeyUnknownMarker = "netbird-ssh-hostkey-unknown"
|
||||||
|
|
||||||
|
var (
|
||||||
|
errPasswordRequired = errors.New(PasswordRequiredMarker)
|
||||||
|
errClientClosed = errors.New("ssh client closed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// errHostKeyUnknown carries the presented fingerprint so Connect can build the
|
||||||
|
// marker message the Java side parses.
|
||||||
|
type errHostKeyUnknown struct {
|
||||||
|
fingerprint string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *errHostKeyUnknown) Error() string {
|
||||||
|
return HostKeyUnknownMarker + ":" + e.fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHTerminalListener receives SSH session events. It is implemented in Java.
|
||||||
|
//
|
||||||
|
// All callbacks are invoked from goroutines and may run concurrently with each
|
||||||
|
// other; the implementation must be safe to call from any thread.
|
||||||
|
type SSHTerminalListener interface {
|
||||||
|
OnConnected()
|
||||||
|
OnData(data []byte)
|
||||||
|
OnClose(reason string)
|
||||||
|
OnError(message string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHClient is a NetBird-aware SSH client exposed to Java via gomobile.
|
||||||
|
//
|
||||||
|
// It dials through the running NetBird tunnel and runs a standard SSH session
|
||||||
|
// on top with PTY enabled. Host-key verification uses the NetBird-provided
|
||||||
|
// peer SSH host keys, identical to the desktop client.
|
||||||
|
type SSHClient struct {
|
||||||
|
nb *Client
|
||||||
|
mu sync.Mutex
|
||||||
|
listener SSHTerminalListener
|
||||||
|
urlOpener URLOpener
|
||||||
|
|
||||||
|
sshClient *gossh.Client
|
||||||
|
session *gossh.Session
|
||||||
|
stdin io.WriteCloser
|
||||||
|
closed bool
|
||||||
|
|
||||||
|
// gen identifies the current connection attempt. Connect and Close bump it,
|
||||||
|
// so an in-flight dial or a reader left over from a previous connection
|
||||||
|
// finds itself stale and stays silent instead of publishing OnConnected or
|
||||||
|
// OnClose for a connection the caller already abandoned.
|
||||||
|
gen uint64
|
||||||
|
dialCancel context.CancelFunc
|
||||||
|
|
||||||
|
// knownHostsConfigDir and knownHostsProfile locate the TOFU store for
|
||||||
|
// regular SSH servers in the profile's preferences. Java supplies them,
|
||||||
|
// since an overlay IP is a different host under a different profile. Empty
|
||||||
|
// until set: without them a regular server cannot be verified and Connect
|
||||||
|
// refuses one.
|
||||||
|
knownHostsConfigDir string
|
||||||
|
knownHostsProfile string
|
||||||
|
// trustHostKey carries the fingerprint the user confirmed on a previous
|
||||||
|
// attempt, so the retry accepts exactly that key and persists it.
|
||||||
|
trustHostKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
|
||||||
|
func NewSSHClient(c *Client) *SSHClient {
|
||||||
|
return &SSHClient{nb: c}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetListener registers the Java listener. Must be called before Connect to
|
||||||
|
// receive any events.
|
||||||
|
func (s *SSHClient) SetListener(l SSHTerminalListener) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.listener = l
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetURLOpener registers the Java URL opener used to display the device-code
|
||||||
|
// authorization page in a Custom Tabs window when the target peer requires
|
||||||
|
// JWT authentication. Must be set before Connect to be effective.
|
||||||
|
func (s *SSHClient) SetURLOpener(opener URLOpener) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.urlOpener = opener
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKnownHostsStore points the TOFU host-key store at a profile's preferences.
|
||||||
|
// Must be set before connecting to a regular SSH server; without it such a
|
||||||
|
// server cannot be verified and Connect refuses one.
|
||||||
|
func (s *SSHClient) SetKnownHostsStore(configDir, profileID string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.knownHostsConfigDir = configDir
|
||||||
|
s.knownHostsProfile = profileID
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrustHostKey records the fingerprint the user confirmed for a regular server,
|
||||||
|
// so the next Connect accepts that exact key and adds it to the known-hosts
|
||||||
|
// store. Passing a fingerprint that no longer matches makes the connect fail
|
||||||
|
// rather than trust a key that changed since the prompt.
|
||||||
|
func (s *SSHClient) TrustHostKey(fingerprint string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.trustHostKey = fingerprint
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect dials the SSH server through the NetBird tunnel and performs the
|
||||||
|
// SSH handshake. It auto-detects the server type via SSH banner inspection
|
||||||
|
// and selects the appropriate authentication path:
|
||||||
|
//
|
||||||
|
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
|
||||||
|
// flow, opens the verification URL through the registered URLOpener, and
|
||||||
|
// uses the resulting token as the SSH password. Host-key verification
|
||||||
|
// uses the NetBird peer registry.
|
||||||
|
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
|
||||||
|
// private key. Host-key verification uses the NetBird peer registry.
|
||||||
|
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
|
||||||
|
// first (so a user-installed NetBird public key works), then falls back
|
||||||
|
// to the supplied password if non-empty. Host-key verification is
|
||||||
|
// trust-on-first-use against the per-profile known-hosts store.
|
||||||
|
//
|
||||||
|
// The password parameter is only consulted for regular SSH servers.
|
||||||
|
func (s *SSHClient) Connect(host string, port int, user, password string) error {
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
return fmt.Errorf("invalid port: %d", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, cfgPath, cc := s.nb.authSnapshot()
|
||||||
|
if cc == nil {
|
||||||
|
return errors.New("netbird client not running")
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return errors.New("netbird config not loaded")
|
||||||
|
}
|
||||||
|
engine := cc.Engine()
|
||||||
|
if engine == nil {
|
||||||
|
return errors.New("netbird engine not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.gen++
|
||||||
|
gen := s.gen
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
serverType := detectServerType(host, port)
|
||||||
|
log.Debugf("SSH server type: %s", serverType)
|
||||||
|
|
||||||
|
authMethods, hostKeyCallback, err := s.buildAuth(cfg, cfgPath, engine, serverType, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
clientConfig := &gossh.ClientConfig{
|
||||||
|
User: user,
|
||||||
|
Auth: authMethods,
|
||||||
|
HostKeyCallback: hostKeyCallback,
|
||||||
|
Timeout: sshDialTimeout,
|
||||||
|
}
|
||||||
|
err = s.dialAndHandshake(gen, host, port, clientConfig)
|
||||||
|
|
||||||
|
// An unknown host key is a prompt, not a failure: return the marker intact
|
||||||
|
// (rootCause would unwrap it) so Java can show the fingerprint and retry.
|
||||||
|
var unknownHost *errHostKeyUnknown
|
||||||
|
if errors.As(err, &unknownHost) {
|
||||||
|
return errors.New(unknownHost.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A regular server may still accept a password, so let the caller ask for
|
||||||
|
// one instead of failing. NetBird servers never use a password, so a
|
||||||
|
// failure there is genuine.
|
||||||
|
if err != nil && serverType != detection.ServerTypeNetBirdJWT &&
|
||||||
|
serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) &&
|
||||||
|
passwordCouldHelp(err, password != "") {
|
||||||
|
return errPasswordRequired
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return rootCause(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSession requests a PTY and starts an interactive shell. Output from
|
||||||
|
// the session is forwarded to the listener via OnData.
|
||||||
|
func (s *SSHClient) StartSession(cols, rows int) error {
|
||||||
|
err := s.startSession(cols, rows)
|
||||||
|
if err != nil {
|
||||||
|
log.Infof("SSH: start session failed: %v", err)
|
||||||
|
return rootCause(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends data to the SSH session stdin.
|
||||||
|
func (s *SSHClient) Write(data []byte) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
stdin := s.stdin
|
||||||
|
s.mu.Unlock()
|
||||||
|
if stdin == nil {
|
||||||
|
return errors.New("ssh session not started")
|
||||||
|
}
|
||||||
|
if _, err := stdin.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("write stdin: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize updates the PTY window size.
|
||||||
|
func (s *SSHClient) Resize(cols, rows int) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
session := s.session
|
||||||
|
s.mu.Unlock()
|
||||||
|
if session == nil {
|
||||||
|
return errors.New("ssh session not started")
|
||||||
|
}
|
||||||
|
return session.WindowChange(rows, cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset makes a closed client usable for another Connect: Close leaves the
|
||||||
|
// one-shot guard set, and clearing it lets the same client back a reconnect.
|
||||||
|
func (s *SSHClient) Reset() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.closed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the SSH session and underlying connection. Safe to call
|
||||||
|
// multiple times.
|
||||||
|
func (s *SSHClient) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.gen++
|
||||||
|
if s.dialCancel != nil {
|
||||||
|
s.dialCancel()
|
||||||
|
s.dialCancel = nil
|
||||||
|
}
|
||||||
|
sshClient := s.sshClient
|
||||||
|
session := s.session
|
||||||
|
stdin := s.stdin
|
||||||
|
s.sshClient = nil
|
||||||
|
s.session = nil
|
||||||
|
s.stdin = nil
|
||||||
|
notify := !s.closed
|
||||||
|
s.closed = true
|
||||||
|
listener := s.listener
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if stdin != nil {
|
||||||
|
if err := stdin.Close(); err != nil {
|
||||||
|
log.Debugf("ssh: stdin close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session != nil {
|
||||||
|
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
log.Debugf("ssh: session close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var firstErr error
|
||||||
|
if sshClient != nil {
|
||||||
|
if err := sshClient.Close(); err != nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if notify && listener != nil {
|
||||||
|
listener.OnClose("closed by client")
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) startSession(cols, rows int) error {
|
||||||
|
log.Debugf("SSH: starting session %dx%d", cols, rows)
|
||||||
|
s.mu.Lock()
|
||||||
|
sshClient := s.sshClient
|
||||||
|
gen := s.gen
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if sshClient == nil {
|
||||||
|
return errors.New("ssh client not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
pty, err := nbssh.StartPTYSession(sshClient, cols, rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if gen != s.gen {
|
||||||
|
s.mu.Unlock()
|
||||||
|
closeQuiet(pty.Session, "stale session")
|
||||||
|
return errClientClosed
|
||||||
|
}
|
||||||
|
s.session = pty.Session
|
||||||
|
s.stdin = pty.Stdin
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
readerDone := make(chan string, 2)
|
||||||
|
go func() { readerDone <- s.readLoop(pty.Stdout, "stdout") }()
|
||||||
|
go func() { readerDone <- s.readLoop(pty.Stderr, "stderr") }()
|
||||||
|
go func() {
|
||||||
|
reason := <-readerDone
|
||||||
|
if second := <-readerDone; reason == "" {
|
||||||
|
reason = second
|
||||||
|
}
|
||||||
|
s.notifyClose(gen, reason)
|
||||||
|
}()
|
||||||
|
log.Debug("SSH: session started, shell running")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, cfgPath string, engine *internal.Engine,
|
||||||
|
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
|
||||||
|
|
||||||
|
switch serverType {
|
||||||
|
case detection.ServerTypeNetBirdJWT:
|
||||||
|
token, err := s.requestJWTToken(cfg, cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("jwt: %w", err)
|
||||||
|
}
|
||||||
|
auths := []gossh.AuthMethod{gossh.Password(token)}
|
||||||
|
return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
|
||||||
|
|
||||||
|
case detection.ServerTypeNetBirdNoJWT:
|
||||||
|
if cfg.SSHKey == "" {
|
||||||
|
return nil, nil, errors.New("no NetBird SSH key available")
|
||||||
|
}
|
||||||
|
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
|
||||||
|
}
|
||||||
|
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
|
||||||
|
return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
|
||||||
|
|
||||||
|
case detection.ServerTypeRegular:
|
||||||
|
var auths []gossh.AuthMethod
|
||||||
|
if cfg.SSHKey != "" {
|
||||||
|
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
|
||||||
|
auths = append(auths, gossh.PublicKeys(signer))
|
||||||
|
} else {
|
||||||
|
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
pw := password
|
||||||
|
auths = append(auths, gossh.Password(pw))
|
||||||
|
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
|
||||||
|
answers := make([]string, len(questions))
|
||||||
|
for i := range questions {
|
||||||
|
answers[i] = pw
|
||||||
|
}
|
||||||
|
return answers, nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if len(auths) == 0 {
|
||||||
|
// Nothing to offer at all: ask for a password rather than failing,
|
||||||
|
// so the caller can retry once the user supplies one.
|
||||||
|
return nil, nil, errPasswordRequired
|
||||||
|
}
|
||||||
|
callback, err := s.tofuHostKeyCallback()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return auths, callback, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("unsupported SSH server type: %v", serverType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tofuHostKeyCallback verifies a regular server's host key against the
|
||||||
|
// per-profile known-hosts store. An unknown host returns errHostKeyUnknown so
|
||||||
|
// Java can show the fingerprint and, once confirmed, retry with the key
|
||||||
|
// trusted; a changed key is rejected outright, as OpenSSH does. When the user
|
||||||
|
// has confirmed a fingerprint, the callback accepts exactly that key and
|
||||||
|
// appends it to the store.
|
||||||
|
func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
configDir := s.knownHostsConfigDir
|
||||||
|
profileID := s.knownHostsProfile
|
||||||
|
trusted := s.trustHostKey
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if configDir == "" || profileID == "" {
|
||||||
|
return nil, errors.New("no known-hosts store configured for regular SSH")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := openKnownHostsStore(configDir, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load known-hosts store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
|
||||||
|
verdict, err := store.verify(hostname, remote, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if verdict == hostKeyMatched {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if verdict == hostKeyChanged {
|
||||||
|
return fmt.Errorf("SSH host key changed for %s (possible attack)", hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
fingerprint := gossh.FingerprintSHA256(key)
|
||||||
|
if trusted == "" {
|
||||||
|
return &errHostKeyUnknown{fingerprint: fingerprint}
|
||||||
|
}
|
||||||
|
if trusted != fingerprint {
|
||||||
|
return fmt.Errorf("SSH host key changed since it was confirmed for %s", hostname)
|
||||||
|
}
|
||||||
|
if err := store.append(hostname, remote, key); err != nil {
|
||||||
|
return fmt.Errorf("persist trusted host key: %w", err)
|
||||||
|
}
|
||||||
|
// The confirmation is spent: now that the key is stored, a later
|
||||||
|
// reconnect must verify against the file, not re-accept this fingerprint.
|
||||||
|
s.mu.Lock()
|
||||||
|
s.trustHostKey = ""
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string) (string, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
urlOpener := s.urlOpener
|
||||||
|
s.mu.Unlock()
|
||||||
|
if urlOpener == nil {
|
||||||
|
return "", errors.New("URL opener not configured for JWT auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create oauth flow: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The status callback covers the browser round-trip, which would
|
||||||
|
// otherwise leave the terminal blank.
|
||||||
|
tokenInfo, err := runOAuthFlow(ctx, flow, urlOpener, func() {
|
||||||
|
s.notifyStatus("Waiting for browser authentication...")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
token := tokenInfo.GetTokenToUse()
|
||||||
|
if token == "" {
|
||||||
|
return "", errors.New("empty token returned by IdP")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tells the client the browser round-trip is over so it can dismiss the
|
||||||
|
// surface it opened, the same way the login and session-extend flows do.
|
||||||
|
// Without it the Custom Tab stays in front of the terminal even though the
|
||||||
|
// token has already been collected.
|
||||||
|
urlOpener.OnLoginSuccess()
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) dialAndHandshake(gen uint64, host string, port int, clientConfig *gossh.ClientConfig) error {
|
||||||
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if gen != s.gen {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return errClientClosed
|
||||||
|
}
|
||||||
|
s.dialCancel = cancel
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
var dialer net.Dialer
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := nbssh.Handshake(ctx, conn, addr, clientConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if gen != s.gen {
|
||||||
|
s.mu.Unlock()
|
||||||
|
closeQuiet(client, "stale ssh client")
|
||||||
|
return errClientClosed
|
||||||
|
}
|
||||||
|
s.sshClient = client
|
||||||
|
listener := s.listener
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if listener != nil {
|
||||||
|
listener.OnConnected()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) readLoop(r io.Reader, name string) string {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
s.mu.Lock()
|
||||||
|
listener := s.listener
|
||||||
|
s.mu.Unlock()
|
||||||
|
if listener != nil {
|
||||||
|
chunk := make([]byte, n)
|
||||||
|
copy(chunk, buf[:n])
|
||||||
|
listener.OnData(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// EOF is a normal shell exit, so report it without a reason.
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
log.Debugf("ssh %s read: %v", name, err)
|
||||||
|
return rootCause(err).Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyStatus writes a progress line to the terminal through the normal
|
||||||
|
// output path, so long steps are visible while nothing else is arriving.
|
||||||
|
func (s *SSHClient) notifyStatus(text string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
listener := s.listener
|
||||||
|
s.mu.Unlock()
|
||||||
|
if listener != nil {
|
||||||
|
listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) notifyClose(gen uint64, reason string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if gen != s.gen || s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
listener := s.listener
|
||||||
|
s.mu.Unlock()
|
||||||
|
if listener != nil {
|
||||||
|
listener.OnClose(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeQuiet(c io.Closer, label string) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
log.Debugf("ssh: close %s: %v", label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectServerType(host string, port int) detection.ServerType {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dialer := &net.Dialer{}
|
||||||
|
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("ssh: server detection failed: %v (assuming regular SSH)", err)
|
||||||
|
return detection.ServerTypeRegular
|
||||||
|
}
|
||||||
|
return serverType
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootCause returns the innermost error of a %w chain, so the terminal shows
|
||||||
|
// "i/o timeout" rather than every layer that added context on the way up.
|
||||||
|
func rootCause(err error) error {
|
||||||
|
for {
|
||||||
|
// A joined error has no single root, so keep it as-is.
|
||||||
|
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next := errors.Unwrap(err)
|
||||||
|
if next == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAuthFailure distinguishes credential rejection from dial, timeout and
|
||||||
|
// host-key errors, which retrying with a password would not fix.
|
||||||
|
func isAuthFailure(err error) bool {
|
||||||
|
if errors.Is(err, errPasswordRequired) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var partial *gossh.PartialSuccessError
|
||||||
|
if errors.As(err, &partial) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(err.Error(), "unable to authenticate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordCouldHelp reports whether prompting for a password again can change
|
||||||
|
// the outcome. gossh lists a method under "attempted methods" only when the
|
||||||
|
// server offered it, so a supplied password that was never attempted means the
|
||||||
|
// server does not accept passwords and the real error should surface instead.
|
||||||
|
func passwordCouldHelp(err error, passwordOffered bool) bool {
|
||||||
|
if !passwordOffered {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "password") || strings.Contains(msg, "keyboard-interactive")
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
|
)
|
||||||
|
|
||||||
|
const knownHostsNamespace = "ssh"
|
||||||
|
|
||||||
|
const (
|
||||||
|
hostKeyUnknown hostKeyVerdict = iota
|
||||||
|
hostKeyMatched
|
||||||
|
hostKeyChanged
|
||||||
|
)
|
||||||
|
|
||||||
|
var knownHostsMu sync.Mutex
|
||||||
|
|
||||||
|
type hostKeyVerdict uint8
|
||||||
|
|
||||||
|
type knownHostsSection struct {
|
||||||
|
KnownHosts []string `json:"knownHosts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type knownHostsStore struct {
|
||||||
|
prefs prefsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveKnownHost deletes every known-hosts entry for host:port from the
|
||||||
|
// profile's store, so a host trusted for a session that is being deleted does
|
||||||
|
// not linger. Java calls this only once no session targets that host, so a
|
||||||
|
// shared host stays trusted. A missing entry is not an error: the goal state
|
||||||
|
// is "absent".
|
||||||
|
func RemoveKnownHost(configDir, profileID, host string, port int) error {
|
||||||
|
store, err := openKnownHostsStore(configDir, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return store.removeHost(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openKnownHostsStore(configDir, profileID string) (*knownHostsStore, error) {
|
||||||
|
prefs, err := newProfilePrefs(configDir, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &knownHostsStore{prefs: prefs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *knownHostsStore) verify(hostname string, remote net.Addr, key gossh.PublicKey) (hostKeyVerdict, error) {
|
||||||
|
lines, err := st.lines()
|
||||||
|
if err != nil {
|
||||||
|
return hostKeyUnknown, err
|
||||||
|
}
|
||||||
|
targets := knownHostsTargets(hostname, remote)
|
||||||
|
|
||||||
|
verdict := hostKeyUnknown
|
||||||
|
for _, line := range lines {
|
||||||
|
pubKey, ok := knownHostsLineKey(line, targets)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pubKey.Type() == key.Type() && bytes.Equal(pubKey.Marshal(), key.Marshal()) {
|
||||||
|
return hostKeyMatched, nil
|
||||||
|
}
|
||||||
|
verdict = hostKeyChanged
|
||||||
|
}
|
||||||
|
return verdict, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *knownHostsStore) append(hostname string, remote net.Addr, key gossh.PublicKey) error {
|
||||||
|
line := knownhosts.Line(knownHostsTargets(hostname, remote), key)
|
||||||
|
|
||||||
|
knownHostsMu.Lock()
|
||||||
|
defer knownHostsMu.Unlock()
|
||||||
|
|
||||||
|
lines, err := st.lines()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: append(lines, line)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *knownHostsStore) removeHost(host string, port int) error {
|
||||||
|
target := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
|
||||||
|
|
||||||
|
knownHostsMu.Lock()
|
||||||
|
defer knownHostsMu.Unlock()
|
||||||
|
|
||||||
|
lines, err := st.lines()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
if knownHostsLineMatches(line, target) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, line)
|
||||||
|
}
|
||||||
|
if len(kept) == len(lines) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: kept})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *knownHostsStore) lines() ([]string, error) {
|
||||||
|
var section knownHostsSection
|
||||||
|
if _, err := st.prefs.Get(knownHostsNamespace, §ion); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return section.KnownHosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func knownHostsTargets(hostname string, remote net.Addr) []string {
|
||||||
|
targets := []string{knownhosts.Normalize(hostname)}
|
||||||
|
if remote != nil {
|
||||||
|
if normalized := knownhosts.Normalize(remote.String()); normalized != targets[0] {
|
||||||
|
targets = append(targets, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func knownHostsLineKey(line string, targets []string) (gossh.PublicKey, bool) {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
_, hosts, pubKey, _, _, err := gossh.ParseKnownHosts([]byte(trimmed))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, host := range hosts {
|
||||||
|
for _, target := range targets {
|
||||||
|
if host == target {
|
||||||
|
return pubKey, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownHostsLineMatches reports whether a known-hosts line's address list
|
||||||
|
// contains the normalized target. Comment and blank lines never match.
|
||||||
|
func knownHostsLineMatches(line, target string) bool {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fields := strings.Fields(trimmed)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, addr := range strings.Split(fields[0], ",") {
|
||||||
|
if addr == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package android
|
||||||
|
|
||||||
|
const (
|
||||||
|
sshSessionsNamespace = "ssh-sessions"
|
||||||
|
maxStoredSSHSessions = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
type sshSessionRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sshSessionsSection struct {
|
||||||
|
Sessions []sshSessionRecord `json:"sessions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHSessionEntry is one stored SSH session, without any credential.
|
||||||
|
type SSHSessionEntry struct {
|
||||||
|
ID string
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHSessionArray wraps stored SSH sessions for gomobile compatibility.
|
||||||
|
type SSHSessionArray struct {
|
||||||
|
items []*SSHSessionEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSHSessionArray creates an empty session array to fill via Add.
|
||||||
|
func NewSSHSessionArray() *SSHSessionArray {
|
||||||
|
return &SSHSessionArray{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add appends a session entry, oldest first.
|
||||||
|
func (a *SSHSessionArray) Add(id, host string, port int, user string) {
|
||||||
|
a.items = append(a.items, &SSHSessionEntry{ID: id, Host: host, Port: port, User: user})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length returns the number of entries.
|
||||||
|
func (a *SSHSessionArray) Length() int {
|
||||||
|
return len(a.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the entry at index i, or nil when out of range.
|
||||||
|
func (a *SSHSessionArray) Get(i int) *SSHSessionEntry {
|
||||||
|
if i < 0 || i >= len(a.items) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.items[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHSessionStore reads and writes a profile's stored SSH sessions.
|
||||||
|
type SSHSessionStore struct {
|
||||||
|
prefs prefsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSHSessionStore opens the session store of the given profile.
|
||||||
|
func NewSSHSessionStore(configDir, profileID string) (*SSHSessionStore, error) {
|
||||||
|
prefs, err := newProfilePrefs(configDir, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SSHSessionStore{prefs: prefs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load returns the stored sessions, oldest first.
|
||||||
|
func (s *SSHSessionStore) Load() (*SSHSessionArray, error) {
|
||||||
|
var section sshSessionsSection
|
||||||
|
if _, err := s.prefs.Get(sshSessionsNamespace, §ion); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := NewSSHSessionArray()
|
||||||
|
for _, record := range section.Sessions {
|
||||||
|
if record.ID == "" || record.Host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Add(record.ID, record.Host, record.Port, record.User)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save replaces the stored sessions, keeping only the newest entries when the
|
||||||
|
// list exceeds the storage cap.
|
||||||
|
func (s *SSHSessionStore) Save(sessions *SSHSessionArray) error {
|
||||||
|
var items []*SSHSessionEntry
|
||||||
|
if sessions != nil {
|
||||||
|
items = sessions.items
|
||||||
|
}
|
||||||
|
if len(items) > maxStoredSSHSessions {
|
||||||
|
items = items[len(items)-maxStoredSSHSessions:]
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]sshSessionRecord, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
records = append(records, sshSessionRecord{ID: item.ID, Host: item.Host, Port: item.Port, User: item.User})
|
||||||
|
}
|
||||||
|
return s.prefs.Put(sshSessionsNamespace, sshSessionsSection{Sessions: records})
|
||||||
|
}
|
||||||
+1
-2
@@ -3,7 +3,6 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/user"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -114,7 +113,7 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get active profile: %v", err)
|
return fmt.Errorf("get active profile: %v", err)
|
||||||
}
|
}
|
||||||
currUser, err := user.Current()
|
currUser, err := profilemanager.InvokingUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get current user: %v", err)
|
return fmt.Errorf("get current user: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
// remoteJobsAllowedFlag opts this peer into running remote jobs (e.g. debug
|
||||||
|
// bundles) requested by the management server. It defaults to false: remote
|
||||||
|
// jobs are an explicit opt-in, and enabling it is a privileged change (see the
|
||||||
|
// daemon gate in client/server), mirroring the SSH server opt-in.
|
||||||
|
const remoteJobsAllowedFlag = "allow-remote-jobs"
|
||||||
|
|
||||||
|
var remoteJobsAllowed bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
upCmd.PersistentFlags().BoolVar(&remoteJobsAllowed, remoteJobsAllowedFlag, false, "Allow the management server to run remote jobs (e.g. debug bundles) on this peer")
|
||||||
|
}
|
||||||
+18
-20
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -53,7 +52,7 @@ var loginCmd = &cobra.Command{
|
|||||||
// nolint
|
// nolint
|
||||||
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, hostName)
|
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, hostName)
|
||||||
}
|
}
|
||||||
username, err := user.Current()
|
username, err := profilemanager.InvokingUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get current user: %v", err)
|
return fmt.Errorf("get current user: %v", err)
|
||||||
}
|
}
|
||||||
@@ -74,7 +73,7 @@ var loginCmd = &cobra.Command{
|
|||||||
if providedSetupKey != "" {
|
if providedSetupKey != "" {
|
||||||
return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers")
|
return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers")
|
||||||
}
|
}
|
||||||
if err := doExtendSession(ctx, cmd); err != nil {
|
if err := doExtendSession(ctx, cmd, activeProf); err != nil {
|
||||||
return fmt.Errorf("extend session failed: %v", err)
|
return fmt.Errorf("extend session failed: %v", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -92,7 +91,7 @@ var loginCmd = &cobra.Command{
|
|||||||
return fmt.Errorf("daemon login failed: %v", err)
|
return fmt.Errorf("daemon login failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Println("Logging successfully")
|
cmd.Println("Login successful")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -176,7 +175,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
|||||||
// (browser + verification URL) and the resulting JWT is forwarded to the
|
// (browser + verification URL) and the resulting JWT is forwarded to the
|
||||||
// management server's ExtendAuthSession RPC. The tunnel stays up
|
// management server's ExtendAuthSession RPC. The tunnel stays up
|
||||||
// throughout — no Down/Up, no network-map resync.
|
// throughout — no Down/Up, no network-map resync.
|
||||||
func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
|
func doExtendSession(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error {
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//nolint
|
//nolint
|
||||||
@@ -190,14 +189,12 @@ func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
|
|||||||
|
|
||||||
// the CLI runs in the user's session, the daemon does not: tell it what we can see
|
// the CLI runs in the user's session, the daemon does not: tell it what we can see
|
||||||
req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: util.HasGraphicalSession()}
|
req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: util.HasGraphicalSession()}
|
||||||
// Pre-fill the IdP login hint from the active profile so the user
|
// Pre-fill the IdP login hint from the resolved profile so the user
|
||||||
// doesn't have to retype their email. Best-effort: we still proceed
|
// doesn't have to retype their email. Best-effort: we still proceed
|
||||||
// without a hint if the lookup fails.
|
// without a hint if the lookup fails.
|
||||||
pm := profilemanager.NewProfileManager()
|
pm := profilemanager.NewProfileManager()
|
||||||
if active, perr := pm.GetActiveProfile(); perr == nil {
|
if profState, perr := pm.GetProfileState(activeProf.ID); perr == nil && profState.Email != "" {
|
||||||
if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" {
|
req.Hint = &profState.Email
|
||||||
req.Hint = &profState.Email
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startResp, err := client.RequestExtendAuthSession(ctx, req)
|
startResp, err := client.RequestExtendAuthSession(ctx, req)
|
||||||
@@ -235,9 +232,11 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
|
|||||||
// switch profile if provided
|
// switch profile if provided
|
||||||
|
|
||||||
if profileName != "" {
|
if profileName != "" {
|
||||||
if err := switchProfileOnDaemon(ctx, pm, profileName, username); err != nil {
|
prof, err := switchProfileOnDaemon(ctx, pm, profileName, username)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("switch profile: %v", err)
|
return nil, fmt.Errorf("switch profile: %v", err)
|
||||||
}
|
}
|
||||||
|
return prof, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
activeProf, err := pm.GetActiveProfile()
|
activeProf, err := pm.GetActiveProfile()
|
||||||
@@ -251,20 +250,19 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
|
|||||||
return activeProf, nil
|
return activeProf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error {
|
func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) (*profilemanager.Profile, error) {
|
||||||
resolvedID, err := switchProfile(ctx, handle, username)
|
resolvedID, err := switchProfile(ctx, handle, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("switch profile on daemon: %v", err)
|
return nil, fmt.Errorf("switch profile on daemon: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := pm.SwitchProfile(resolvedID); err != nil {
|
if err := pm.SwitchProfile(resolvedID); err != nil {
|
||||||
return fmt.Errorf("switch profile: %v", err)
|
return nil, fmt.Errorf("switch profile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed to connect to service CLI interface %v", err)
|
return nil, fmt.Errorf("connect to service CLI interface: %w", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -272,17 +270,17 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage
|
|||||||
|
|
||||||
status, err := client.Status(ctx, &proto.StatusRequest{})
|
status, err := client.Status(ctx, &proto.StatusRequest{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to get daemon status: %v", err)
|
return nil, fmt.Errorf("unable to get daemon status: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.Status == string(internal.StatusConnected) {
|
if status.Status == string(internal.StatusConnected) {
|
||||||
if _, err := client.Down(ctx, &proto.DownRequest{}); err != nil {
|
if _, err := client.Down(ctx, &proto.DownRequest{}); err != nil {
|
||||||
log.Errorf("call service down method: %v", err)
|
log.Errorf("call service down method: %v", err)
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return &profilemanager.Profile{ID: resolvedID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// switchProfile asks the daemon to switch to the profile identified by
|
// switchProfile asks the daemon to switch to the profile identified by
|
||||||
@@ -345,7 +343,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("foreground login failed: %v", err)
|
return fmt.Errorf("foreground login failed: %v", err)
|
||||||
}
|
}
|
||||||
cmd.Println("Logging successfully")
|
cmd.Println("Login successful")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/user"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ var logoutCmd = &cobra.Command{
|
|||||||
if profileName != "" {
|
if profileName != "" {
|
||||||
req.ProfileName = &profileName
|
req.ProfileName = &profileName
|
||||||
|
|
||||||
currUser, err := user.Current()
|
currUser, err := profilemanager.InvokingUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get current user: %v", err)
|
return fmt.Errorf("get current user: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/user"
|
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
@@ -97,7 +96,7 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
currUser, err := user.Current()
|
currUser, err := profilemanager.InvokingUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get current user: %w", err)
|
return fmt.Errorf("get current user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -138,7 +137,7 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
currUser, err := user.Current()
|
currUser, err := profilemanager.InvokingUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get current user: %w", err)
|
return fmt.Errorf("get current user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -179,7 +178,7 @@ func renameProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
currUser, err := user.Current()
|
currUser, err := profilemanager.InvokingUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get current user: %w", err)
|
return fmt.Errorf("get current user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -233,7 +232,7 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
currUser, err := user.Current()
|
currUser, err := profilemanager.InvokingUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get current user: %w", err)
|
return fmt.Errorf("get current user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -261,7 +260,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
|
|||||||
profileManager := profilemanager.NewProfileManager()
|
profileManager := profilemanager.NewProfileManager()
|
||||||
handle := args[0]
|
handle := args[0]
|
||||||
|
|
||||||
currUser, err := user.Current()
|
currUser, err := profilemanager.InvokingUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get current user: %w", err)
|
return fmt.Errorf("get current user: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
|
|
||||||
"github.com/netbirdio/netbird/client/anonymize"
|
"github.com/netbirdio/netbird/client/anonymize"
|
||||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||||
|
"github.com/netbirdio/netbird/client/internal/localmetrics"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ const (
|
|||||||
dnsResolverAddress = "dns-resolver-address"
|
dnsResolverAddress = "dns-resolver-address"
|
||||||
enableRosenpassFlag = "enable-rosenpass"
|
enableRosenpassFlag = "enable-rosenpass"
|
||||||
rosenpassPermissiveFlag = "rosenpass-permissive"
|
rosenpassPermissiveFlag = "rosenpass-permissive"
|
||||||
|
enableLocalMetricsFlag = "enable-local-metrics"
|
||||||
|
localMetricsAddressFlag = "local-metrics-address"
|
||||||
preSharedKeyFlag = "preshared-key"
|
preSharedKeyFlag = "preshared-key"
|
||||||
interfaceNameFlag = "interface-name"
|
interfaceNameFlag = "interface-name"
|
||||||
wireguardPortFlag = "wireguard-port"
|
wireguardPortFlag = "wireguard-port"
|
||||||
@@ -80,6 +83,8 @@ var (
|
|||||||
updateSettingsDisabled bool
|
updateSettingsDisabled bool
|
||||||
captureEnabled bool
|
captureEnabled bool
|
||||||
networksDisabled bool
|
networksDisabled bool
|
||||||
|
localMetricsEnabled bool
|
||||||
|
localMetricsAddr string
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "netbird",
|
Use: "netbird",
|
||||||
@@ -215,6 +220,8 @@ func init() {
|
|||||||
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
|
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
|
||||||
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
|
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
|
||||||
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
|
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
|
||||||
|
upCmd.PersistentFlags().BoolVar(&localMetricsEnabled, enableLocalMetricsFlag, false, "Enables a local Prometheus /metrics endpoint exposing connection state (peers, latency, P2P vs relay).")
|
||||||
|
upCmd.PersistentFlags().StringVar(&localMetricsAddr, localMetricsAddressFlag, localmetrics.DefaultListenAddress, "Listen address of the local Prometheus /metrics endpoint.")
|
||||||
upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.")
|
upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.")
|
||||||
_ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable")
|
_ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable")
|
||||||
|
|
||||||
|
|||||||
@@ -41,13 +41,15 @@ func daemonServerOptions(network string) []grpc.ServerOption {
|
|||||||
if network == "tcp" {
|
if network == "tcp" {
|
||||||
log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over 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, "+
|
"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)
|
"deregistration) will be denied, and the SSH JWT cache is neither filled nor served. "+
|
||||||
|
"Use a unix socket, or npipe:// on Windows", daemonAddr)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
creds := ipcauth.NewTransportCredentials()
|
creds := ipcauth.NewTransportCredentials() //nolint:staticcheck
|
||||||
if creds == nil {
|
if creds == nil { //nolint:staticcheck // nil only on platforms without a peer-identity primitive
|
||||||
log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS)
|
log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied "+
|
||||||
|
"and the SSH JWT cache is neither filled nor served", runtime.GOOS)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if network == "npipe" {
|
if network == "npipe" {
|
||||||
listener, path, err := listenNamedPipe(address)
|
listener, path, err := listenNamedPipe(address) //nolint:staticcheck
|
||||||
if err != nil {
|
if err != nil { //nolint:staticcheck // always errors on non-Windows builds
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &socketListener{Listener: listener, network: network, address: path}, nil
|
return &socketListener{Listener: listener, network: network, address: path}, nil
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"go.uber.org/mock/gomock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -124,7 +124,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
|||||||
|
|
||||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||||
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
|
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
|
||||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config)
|
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil)
|
||||||
|
|
||||||
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
|
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+167
-63
@@ -2,10 +2,10 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os/user"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -48,6 +48,8 @@ const (
|
|||||||
profileNameDesc = "profile name to use for the login. If not specified, the last used profile will be used."
|
profileNameDesc = "profile name to use for the login. If not specified, the last used profile will be used."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errDaemonActiveProfileUnsupported = errors.New("daemon does not support active profile lookup")
|
||||||
|
|
||||||
var (
|
var (
|
||||||
foregroundMode bool
|
foregroundMode bool
|
||||||
dnsLabels []string
|
dnsLabels []string
|
||||||
@@ -122,23 +124,25 @@ func upFunc(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
pm := profilemanager.NewProfileManager()
|
pm := profilemanager.NewProfileManager()
|
||||||
|
|
||||||
username, err := user.Current()
|
username, err := profilemanager.InvokingUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get current user: %v", err)
|
return fmt.Errorf("get current user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var activeProf *profilemanager.Profile
|
||||||
var profileSwitched bool
|
var profileSwitched bool
|
||||||
// switch profile if provided
|
// switch profile if provided
|
||||||
if profileName != "" {
|
if profileName != "" {
|
||||||
if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); err != nil {
|
activeProf, err = switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("switch profile: %v", err)
|
return fmt.Errorf("switch profile: %v", err)
|
||||||
}
|
}
|
||||||
profileSwitched = true
|
profileSwitched = true
|
||||||
}
|
} else {
|
||||||
|
activeProf, err = pm.GetActiveProfile()
|
||||||
activeProf, err := pm.GetActiveProfile()
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("get active profile: %v", err)
|
||||||
return fmt.Errorf("get active profile: %v", err)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if foregroundMode {
|
if foregroundMode {
|
||||||
@@ -150,13 +154,15 @@ func upFunc(cmd *cobra.Command, args []string) error {
|
|||||||
// switchOrCreateProfile switches the active profile to the one identified by
|
// switchOrCreateProfile switches the active profile to the one identified by
|
||||||
// handle, creating it first when it does not exist yet. This restores the
|
// handle, creating it first when it does not exist yet. This restores the
|
||||||
// pre-0.73 behaviour where `netbird up --profile <name>` auto-creates a
|
// pre-0.73 behaviour where `netbird up --profile <name>` auto-creates a
|
||||||
// missing profile instead of failing.
|
// missing profile instead of failing. Returns the daemon-resolved profile so
|
||||||
func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error {
|
// callers act on it directly instead of re-reading the local state, which is
|
||||||
|
// not updated under sudo.
|
||||||
|
func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) (*profilemanager.Profile, error) {
|
||||||
resolvedID, err := switchProfile(ctx, handle, username)
|
resolvedID, err := switchProfile(ctx, handle, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
st, ok := gstatus.FromError(err)
|
st, ok := gstatus.FromError(err)
|
||||||
if !ok || st.Code() != codes.NotFound {
|
if !ok || st.Code() != codes.NotFound {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Don't fail immediately on a create error: a concurrent run may
|
// Don't fail immediately on a create error: a concurrent run may
|
||||||
// have created the profile between the NotFound above and this
|
// have created the profile between the NotFound above and this
|
||||||
@@ -165,16 +171,16 @@ func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManage
|
|||||||
_, createErr := createProfile(ctx, handle, username)
|
_, createErr := createProfile(ctx, handle, username)
|
||||||
if resolvedID, err = switchProfile(ctx, handle, username); err != nil {
|
if resolvedID, err = switchProfile(ctx, handle, username); err != nil {
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
return fmt.Errorf("create profile: %w", createErr)
|
return nil, fmt.Errorf("create profile: %w", createErr)
|
||||||
}
|
}
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := pm.SwitchProfile(resolvedID); err != nil {
|
if err := pm.SwitchProfile(resolvedID); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
return nil
|
return &profilemanager.Profile{ID: resolvedID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createProfile dials the daemon and creates a new profile with the given
|
// createProfile dials the daemon and creates a new profile with the given
|
||||||
@@ -302,6 +308,30 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
|||||||
return fmt.Errorf("unable to get daemon status: %v", err)
|
return fmt.Errorf("unable to get daemon status: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Under sudo the invoking user's local active-profile mirror is never
|
||||||
|
// written (the SwitchProfile write is a no-op), and plain root has no
|
||||||
|
// invoking user at all — so the mirror read into activeProf above is stale
|
||||||
|
// or defaulted and must not drive the daemon. With no --profile to make the
|
||||||
|
// choice explicit, take the profile the daemon already holds for this user
|
||||||
|
// instead: it stays on the user's current profile rather than silently
|
||||||
|
// switching to the mirror's default, and refuses when the daemon is on
|
||||||
|
// another user's profile.
|
||||||
|
if profileName == "" && !profilemanager.MirrorIsAuthoritative() {
|
||||||
|
u, err := profilemanager.InvokingUser()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get current user: %v", err)
|
||||||
|
}
|
||||||
|
resolved, err := daemonActiveProfileForUser(ctx, client, u.Username)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errDaemonActiveProfileUnsupported):
|
||||||
|
log.Warnf("keeping the locally resolved profile: %v", err)
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
activeProf = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if status.Status == string(internal.StatusConnected) {
|
if status.Status == string(internal.StatusConnected) {
|
||||||
if !profileSwitched {
|
if !profileSwitched {
|
||||||
cmd.Println("Already connected")
|
cmd.Println("Already connected")
|
||||||
@@ -314,7 +344,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
username, err := user.Current()
|
username, err := profilemanager.InvokingUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get current user: %v", err)
|
return fmt.Errorf("get current user: %v", err)
|
||||||
}
|
}
|
||||||
@@ -398,26 +428,21 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest {
|
// setBoolPtrIfChanged points dst at a copy of val when the named bool flag was
|
||||||
var req proto.SetConfigRequest
|
// explicitly set on cmd. It collapses the repeated
|
||||||
req.ProfileName = profileName
|
// "if cmd.Flag(x).Changed { field = &val }" pattern in the request builders into
|
||||||
req.Username = username
|
// a single call, keeping their cognitive complexity within bounds.
|
||||||
|
func setBoolPtrIfChanged(cmd *cobra.Command, name string, dst **bool, val bool) {
|
||||||
req.ManagementUrl = managementURL
|
if cmd.Flag(name).Changed {
|
||||||
req.AdminURL = adminURL
|
dst2 := val
|
||||||
req.NatExternalIPs = natExternalIPs
|
*dst = &dst2
|
||||||
req.CustomDNSAddress = customDNSAddressConverted
|
|
||||||
req.ExtraIFaceBlacklist = extraIFaceBlackList
|
|
||||||
req.DnsLabels = dnsLabelsValidated.ToPunycodeList()
|
|
||||||
req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0
|
|
||||||
req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0
|
|
||||||
|
|
||||||
if cmd.Flag(enableRosenpassFlag).Changed {
|
|
||||||
req.RosenpassEnabled = &rosenpassEnabled
|
|
||||||
}
|
|
||||||
if cmd.Flag(rosenpassPermissiveFlag).Changed {
|
|
||||||
req.RosenpassPermissive = &rosenpassPermissive
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSSHSetConfigFields copies the SSH server flags the user actually
|
||||||
|
// passed into req, leaving the rest unset so the daemon keeps the
|
||||||
|
// persisted values.
|
||||||
|
func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) {
|
||||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||||
req.ServerSSHAllowed = &serverSSHAllowed
|
req.ServerSSHAllowed = &serverSSHAllowed
|
||||||
}
|
}
|
||||||
@@ -440,6 +465,31 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
|||||||
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
||||||
req.SshJWTCacheTTL = &sshJWTCacheTTL32
|
req.SshJWTCacheTTL = &sshJWTCacheTTL32
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest {
|
||||||
|
var req proto.SetConfigRequest
|
||||||
|
req.ProfileName = profileName
|
||||||
|
req.Username = username
|
||||||
|
|
||||||
|
req.ManagementUrl = managementURL
|
||||||
|
req.AdminURL = adminURL
|
||||||
|
req.NatExternalIPs = natExternalIPs
|
||||||
|
req.CustomDNSAddress = customDNSAddressConverted
|
||||||
|
req.ExtraIFaceBlacklist = extraIFaceBlackList
|
||||||
|
req.DnsLabels = dnsLabelsValidated.ToPunycodeList()
|
||||||
|
req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0
|
||||||
|
req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0
|
||||||
|
|
||||||
|
if cmd.Flag(enableRosenpassFlag).Changed {
|
||||||
|
req.RosenpassEnabled = &rosenpassEnabled
|
||||||
|
}
|
||||||
|
if cmd.Flag(rosenpassPermissiveFlag).Changed {
|
||||||
|
req.RosenpassPermissive = &rosenpassPermissive
|
||||||
|
}
|
||||||
|
setSSHSetConfigFields(&req, cmd)
|
||||||
|
setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &req.RemoteJobsAllowed, remoteJobsAllowed)
|
||||||
|
|
||||||
if cmd.Flag(interfaceNameFlag).Changed {
|
if cmd.Flag(interfaceNameFlag).Changed {
|
||||||
if err := parseInterfaceName(interfaceName); err != nil {
|
if err := parseInterfaceName(interfaceName); err != nil {
|
||||||
log.Errorf("parse interface name: %v", err)
|
log.Errorf("parse interface name: %v", err)
|
||||||
@@ -499,6 +549,13 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
|||||||
req.DisableIpv6 = &disableIPv6
|
req.DisableIpv6 = &disableIPv6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(enableLocalMetricsFlag).Changed {
|
||||||
|
req.EnableLocalMetrics = &localMetricsEnabled
|
||||||
|
}
|
||||||
|
if cmd.Flag(localMetricsAddressFlag).Changed {
|
||||||
|
req.LocalMetricsAddress = &localMetricsAddr
|
||||||
|
}
|
||||||
|
|
||||||
return &req
|
return &req
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +580,7 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
|||||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||||
ic.ServerSSHAllowed = &serverSSHAllowed
|
ic.ServerSSHAllowed = &serverSSHAllowed
|
||||||
}
|
}
|
||||||
|
setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &ic.RemoteJobsAllowed, remoteJobsAllowed)
|
||||||
|
|
||||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||||
ic.EnableSSHRoot = &enableSSHRoot
|
ic.EnableSSHRoot = &enableSSHRoot
|
||||||
@@ -616,9 +674,45 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
|||||||
ic.DisableIPv6 = &disableIPv6
|
ic.DisableIPv6 = &disableIPv6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(enableLocalMetricsFlag).Changed {
|
||||||
|
ic.LocalMetricsEnabled = &localMetricsEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(localMetricsAddressFlag).Changed {
|
||||||
|
ic.LocalMetricsAddress = &localMetricsAddr
|
||||||
|
}
|
||||||
|
|
||||||
return &ic, nil
|
return &ic, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setSSHLoginFields copies the SSH server flags the user actually passed
|
||||||
|
// into req, leaving the rest unset so the daemon keeps the persisted
|
||||||
|
// values.
|
||||||
|
func setSSHLoginFields(req *proto.LoginRequest, cmd *cobra.Command) {
|
||||||
|
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||||
|
req.ServerSSHAllowed = &serverSSHAllowed
|
||||||
|
}
|
||||||
|
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||||
|
req.EnableSSHRoot = &enableSSHRoot
|
||||||
|
}
|
||||||
|
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
||||||
|
req.EnableSSHSFTP = &enableSSHSFTP
|
||||||
|
}
|
||||||
|
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
||||||
|
req.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
||||||
|
}
|
||||||
|
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
||||||
|
req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
||||||
|
}
|
||||||
|
if cmd.Flag(disableSSHAuthFlag).Changed {
|
||||||
|
req.DisableSSHAuth = &disableSSHAuth
|
||||||
|
}
|
||||||
|
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
||||||
|
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
||||||
|
req.SshJWTCacheTTL = &sshJWTCacheTTL32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) {
|
func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte, cmd *cobra.Command) (*proto.LoginRequest, error) {
|
||||||
loginRequest := proto.LoginRequest{
|
loginRequest := proto.LoginRequest{
|
||||||
SetupKey: providedSetupKey,
|
SetupKey: providedSetupKey,
|
||||||
@@ -645,39 +739,21 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
|||||||
loginRequest.RosenpassPermissive = &rosenpassPermissive
|
loginRequest.RosenpassPermissive = &rosenpassPermissive
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
setSSHLoginFields(&loginRequest, cmd)
|
||||||
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &loginRequest.RemoteJobsAllowed, remoteJobsAllowed)
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
|
||||||
loginRequest.EnableSSHRoot = &enableSSHRoot
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(enableSSHSFTPFlag).Changed {
|
|
||||||
loginRequest.EnableSSHSFTP = &enableSSHSFTP
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(enableSSHLocalPortForwardFlag).Changed {
|
|
||||||
loginRequest.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
|
|
||||||
loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(disableSSHAuthFlag).Changed {
|
|
||||||
loginRequest.DisableSSHAuth = &disableSSHAuth
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
|
|
||||||
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
|
||||||
loginRequest.SshJWTCacheTTL = &sshJWTCacheTTL32
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flag(disableAutoConnectFlag).Changed {
|
if cmd.Flag(disableAutoConnectFlag).Changed {
|
||||||
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(enableLocalMetricsFlag).Changed {
|
||||||
|
loginRequest.EnableLocalMetrics = &localMetricsEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Flag(localMetricsAddressFlag).Changed {
|
||||||
|
loginRequest.LocalMetricsAddress = &localMetricsAddr
|
||||||
|
}
|
||||||
|
|
||||||
if cmd.Flag(interfaceNameFlag).Changed {
|
if cmd.Flag(interfaceNameFlag).Changed {
|
||||||
if err := parseInterfaceName(interfaceName); err != nil {
|
if err := parseInterfaceName(interfaceName); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -849,3 +925,31 @@ func isValidAddrPort(input string) bool {
|
|||||||
_, err := netip.ParseAddrPort(input)
|
_, err := netip.ParseAddrPort(input)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// daemonActiveProfileForUser returns the profile the daemon currently holds for
|
||||||
|
// username, for the no --profile case where the local mirror is not
|
||||||
|
// authoritative (sudo or plain root). It returns that profile when the daemon
|
||||||
|
// owns it for this user or when the profile is unowned (empty username, as on a
|
||||||
|
// fresh install), so the caller acts on the daemon's real state instead of the
|
||||||
|
// stale mirror. It denies with a --profile hint when the daemon is on another
|
||||||
|
// user's profile, when the lookup fails, or when the daemon reports no active
|
||||||
|
// profile. Returns errDaemonActiveProfileUnsupported when the daemon predates
|
||||||
|
// the RPC; the caller keeps the mirror-derived profile in that case.
|
||||||
|
func daemonActiveProfileForUser(ctx context.Context, client proto.DaemonServiceClient, username string) (*profilemanager.Profile, error) {
|
||||||
|
active, err := client.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
|
||||||
|
if err != nil {
|
||||||
|
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unimplemented {
|
||||||
|
return nil, fmt.Errorf("%w: %v", errDaemonActiveProfileUnsupported, err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("pass --profile to choose the profile explicitly: the daemon's active profile could not be verified: %v", err)
|
||||||
|
}
|
||||||
|
if active.GetId() == "" {
|
||||||
|
return nil, fmt.Errorf("pass --profile to choose the profile explicitly: the daemon reported no active profile")
|
||||||
|
}
|
||||||
|
if active.GetUsername() != "" && active.GetUsername() != username {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"pass --profile to choose the profile explicitly: the daemon's active profile is %q (user %q) but this invocation runs for %q",
|
||||||
|
active.GetProfileName(), active.GetUsername(), username)
|
||||||
|
}
|
||||||
|
return &profilemanager.Profile{ID: profilemanager.ID(active.GetId())}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
gstatus "google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeActiveProfileClient struct {
|
||||||
|
proto.DaemonServiceClient
|
||||||
|
resp *proto.GetActiveProfileResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeActiveProfileClient) GetActiveProfile(_ context.Context, _ *proto.GetActiveProfileRequest, _ ...grpc.CallOption) (*proto.GetActiveProfileResponse, error) {
|
||||||
|
return f.resp, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonActiveProfileForUserReturnsOwnProfile(t *testing.T) {
|
||||||
|
client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: "root"}}
|
||||||
|
prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, prof)
|
||||||
|
assert.Equal(t, profilemanager.ID("default"), prof.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonActiveProfileForUserReturnsUnownedProfile(t *testing.T) {
|
||||||
|
client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: ""}}
|
||||||
|
prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, prof)
|
||||||
|
assert.Equal(t, profilemanager.ID("default"), prof.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonActiveProfileForUserKeepsDaemonProfileOverStaleMirror(t *testing.T) {
|
||||||
|
client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "ab12", ProfileName: "work", Username: "misha"}}
|
||||||
|
prof, err := daemonActiveProfileForUser(context.Background(), client, "misha")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, prof)
|
||||||
|
assert.Equal(t, profilemanager.ID("ab12"), prof.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonActiveProfileForUserRejectsOtherUsersProfile(t *testing.T) {
|
||||||
|
client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "ab12", ProfileName: "work", Username: "misha"}}
|
||||||
|
prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, prof)
|
||||||
|
assert.Contains(t, err.Error(), "--profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonActiveProfileForUserRejectsOtherUsersDefaultProfile(t *testing.T) {
|
||||||
|
client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{Id: "default", ProfileName: "default", Username: "misha"}}
|
||||||
|
prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, prof)
|
||||||
|
assert.Contains(t, err.Error(), "--profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonActiveProfileForUserRejectsLookupError(t *testing.T) {
|
||||||
|
client := &fakeActiveProfileClient{err: gstatus.Error(codes.Internal, "boom")}
|
||||||
|
prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, prof)
|
||||||
|
assert.Contains(t, err.Error(), "--profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonActiveProfileForUserRejectsEmptyResponse(t *testing.T) {
|
||||||
|
client := &fakeActiveProfileClient{resp: &proto.GetActiveProfileResponse{}}
|
||||||
|
prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, prof)
|
||||||
|
assert.Contains(t, err.Error(), "--profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonActiveProfileForUserKeepsMirrorWhenDaemonWithoutRPC(t *testing.T) {
|
||||||
|
client := &fakeActiveProfileClient{err: gstatus.Error(codes.Unimplemented, "unknown method")}
|
||||||
|
prof, err := daemonActiveProfileForUser(context.Background(), client, "root")
|
||||||
|
require.ErrorIs(t, err, errDaemonActiveProfileUnsupported)
|
||||||
|
assert.Nil(t, prof)
|
||||||
|
}
|
||||||
+25
-7
@@ -21,7 +21,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/auth"
|
"github.com/netbirdio/netbird/client/internal/auth"
|
||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
sshcommon "github.com/netbirdio/netbird/client/ssh"
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
"github.com/netbirdio/netbird/shared/management/domain"
|
"github.com/netbirdio/netbird/shared/management/domain"
|
||||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
@@ -85,12 +85,24 @@ type Options struct {
|
|||||||
DisableIPv6 bool
|
DisableIPv6 bool
|
||||||
// BlockInbound blocks all inbound connections from peers
|
// BlockInbound blocks all inbound connections from peers
|
||||||
BlockInbound bool
|
BlockInbound bool
|
||||||
|
// EnableRosenpass enables the Rosenpass post-quantum key exchange.
|
||||||
|
EnableRosenpass bool
|
||||||
|
// RosenpassPermissive lets a Rosenpass-enabled peer still connect to peers
|
||||||
|
// that do not run Rosenpass (falling back to the plain WireGuard PSK).
|
||||||
|
RosenpassPermissive bool
|
||||||
// BlockLANAccess blocks the embedded peer from reaching the host's
|
// BlockLANAccess blocks the embedded peer from reaching the host's
|
||||||
// LAN (RFC 1918, link-local, loopback) when it's used as a routing
|
// LAN (RFC 1918, link-local, loopback) when it's used as a routing
|
||||||
// peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
|
// peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
|
||||||
// when the embedded client must never act as a stepping stone into
|
// when the embedded client must never act as a stepping stone into
|
||||||
// the host's local network (e.g. the proxy's overlay peer).
|
// the host's local network (e.g. the proxy's overlay peer).
|
||||||
BlockLANAccess bool
|
BlockLANAccess bool
|
||||||
|
// LazyConnectionEnabled is a tri-state local override for lazy connections,
|
||||||
|
// mirroring the NB_LAZY_CONN env var. Nil defers to the management feature
|
||||||
|
// flag; a set value overrides it in both directions. A short-lived client
|
||||||
|
// that reaches only a few known peers can set this to false, so its peers
|
||||||
|
// connect eagerly and the first request does not wait for the connection to
|
||||||
|
// be established.
|
||||||
|
LazyConnectionEnabled *bool
|
||||||
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
||||||
WireguardPort *int
|
WireguardPort *int
|
||||||
// MTU is the MTU for the tunnel interface.
|
// MTU is the MTU for the tunnel interface.
|
||||||
@@ -203,6 +215,8 @@ func New(opts Options) (*Client, error) {
|
|||||||
DisableIPv6: &opts.DisableIPv6,
|
DisableIPv6: &opts.DisableIPv6,
|
||||||
BlockInbound: &opts.BlockInbound,
|
BlockInbound: &opts.BlockInbound,
|
||||||
BlockLANAccess: &opts.BlockLANAccess,
|
BlockLANAccess: &opts.BlockLANAccess,
|
||||||
|
RosenpassEnabled: &opts.EnableRosenpass,
|
||||||
|
RosenpassPermissive: &opts.RosenpassPermissive,
|
||||||
WireguardPort: opts.WireguardPort,
|
WireguardPort: opts.WireguardPort,
|
||||||
MTU: opts.MTU,
|
MTU: opts.MTU,
|
||||||
DNSLabels: parsedLabels,
|
DNSLabels: parsedLabels,
|
||||||
@@ -220,6 +234,15 @@ func New(opts Options) (*Client, error) {
|
|||||||
config.PrivateKey = opts.PrivateKey
|
config.PrivateKey = opts.PrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.LazyConnectionEnabled != nil {
|
||||||
|
// Runtime-only override, read back through lazyconn.ParseState; a set value
|
||||||
|
// wins over the management feature flag in both directions.
|
||||||
|
config.LazyConnection = "off"
|
||||||
|
if *opts.LazyConnectionEnabled {
|
||||||
|
config.LazyConnection = "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if opts.Performance.PreallocatedBuffersPerPool != nil {
|
if opts.Performance.PreallocatedBuffersPerPool != nil {
|
||||||
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
|
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
|
||||||
}
|
}
|
||||||
@@ -521,12 +544,7 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
storedKey, found := engine.GetPeerSSHKey(peerAddress)
|
return nbssh.PeerKeyLookup(engine.GetPeerSSHKey).VerifySSHHostKey(peerAddress, key)
|
||||||
if !found {
|
|
||||||
return sshcommon.ErrPeerNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
|
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"go.uber.org/mock/gomock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string {
|
|||||||
|
|
||||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||||
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
|
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
|
||||||
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg)
|
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil)
|
||||||
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -763,7 +763,7 @@ func (r *router) addNatRule(pair firewall.RouterPair) error {
|
|||||||
exprs = append(exprs, sourceExp...)
|
exprs = append(exprs, sourceExp...)
|
||||||
exprs = append(exprs, destExp...)
|
exprs = append(exprs, destExp...)
|
||||||
|
|
||||||
var markValue uint32 = nbnet.PreroutingFwmarkMasquerade
|
markValue := nbnet.PreroutingFwmarkMasquerade
|
||||||
if pair.Inverse {
|
if pair.Inverse {
|
||||||
markValue = nbnet.PreroutingFwmarkMasqueradeReturn
|
markValue = nbnet.PreroutingFwmarkMasqueradeReturn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"go.uber.org/mock/gomock"
|
||||||
"github.com/google/gopacket"
|
"github.com/google/gopacket"
|
||||||
"github.com/google/gopacket/layers"
|
"github.com/google/gopacket/layers"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"go.uber.org/mock/gomock"
|
||||||
"github.com/google/gopacket/layers"
|
"github.com/google/gopacket/layers"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -16,28 +16,52 @@ import (
|
|||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
nbnet "github.com/netbirdio/netbird/client/net"
|
nbnet "github.com/netbirdio/netbird/client/net"
|
||||||
|
"github.com/netbirdio/netbird/client/netevents/sweep"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sweeper registers in-flight dials for the network change sweep.
|
||||||
|
type Sweeper interface {
|
||||||
|
StartDial(ctx context.Context) *sweep.Dial
|
||||||
|
}
|
||||||
|
|
||||||
func WithCustomDialer(_ bool, _ string) grpc.DialOption {
|
func WithCustomDialer(_ bool, _ string) grpc.DialOption {
|
||||||
|
return grpc.WithContextDialer(dialContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSweeper dials like WithCustomDialer but registers connections and
|
||||||
|
// dials with the sweeper. Append it after WithCustomDialer: gRPC applies
|
||||||
|
// dial options in order, so the later context dialer wins.
|
||||||
|
func WithSweeper(sweeper Sweeper) grpc.DialOption {
|
||||||
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||||
if runtime.GOOS == "linux" {
|
dial := sweeper.StartDial(ctx)
|
||||||
currentUser, err := user.Current()
|
defer dial.Release()
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
conn, err := dialContext(dial.Ctx(), addr)
|
||||||
if currentUser.Uid != "0" {
|
|
||||||
log.Debug("Not running as root, using standard dialer")
|
|
||||||
dialer := &net.Dialer{}
|
|
||||||
return dialer.DialContext(ctx, "tcp", addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return conn, nil
|
return dial.WrapConn(conn)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dialContext(ctx context.Context, addr string) (net.Conn, error) {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
currentUser, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the custom dialer requires root permissions which are not required for use cases run as non-root
|
||||||
|
if currentUser.Uid != "0" {
|
||||||
|
log.Debug("Not running as root, using standard dialer")
|
||||||
|
dialer := &net.Dialer{}
|
||||||
|
return dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
package grpc
|
package grpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/netevents/sweep"
|
||||||
"github.com/netbirdio/netbird/util/wsproxy/client"
|
"github.com/netbirdio/netbird/util/wsproxy/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sweeper registers in-flight dials for the network change sweep.
|
||||||
|
type Sweeper interface {
|
||||||
|
StartDial(ctx context.Context) *sweep.Dial
|
||||||
|
}
|
||||||
|
|
||||||
// WithCustomDialer returns a gRPC dial option that uses WebSocket transport for WASM/JS environments.
|
// WithCustomDialer returns a gRPC dial option that uses WebSocket transport for WASM/JS environments.
|
||||||
// The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal").
|
// The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal").
|
||||||
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
|
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
|
||||||
return client.WithWebSocketDialer(tlsEnabled, component)
|
return client.WithWebSocketDialer(tlsEnabled, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSweeper is a no-op on WASM/JS: there is no network change signal.
|
||||||
|
func WithSweeper(_ Sweeper) grpc.DialOption {
|
||||||
|
return grpc.EmptyDialOption{}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChangeWatcher exposes OS network availability transitions.
|
||||||
|
type ChangeWatcher interface {
|
||||||
|
Changed() <-chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry mirrors backoff.Retry, but the sleep between attempts also wakes on
|
||||||
|
// OS network availability transitions: an operation cut down by a network
|
||||||
|
// change retries the moment the network settles instead of sleeping through
|
||||||
|
// the recovery. A nil watcher never fires, leaving plain backoff.Retry
|
||||||
|
// behavior.
|
||||||
|
func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, watcher ChangeWatcher) error {
|
||||||
|
bo.Reset()
|
||||||
|
for {
|
||||||
|
err := operation()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var permanent *backoff.PermanentError
|
||||||
|
if errors.As(err, &permanent) {
|
||||||
|
return permanent.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
next := bo.NextBackOff()
|
||||||
|
if next == backoff.Stop {
|
||||||
|
if cerr := ctx.Err(); cerr != nil {
|
||||||
|
return cerr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var changed <-chan struct{}
|
||||||
|
if watcher != nil {
|
||||||
|
changed = watcher.Changed()
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(next)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
case <-changed:
|
||||||
|
timer.Stop()
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/netevents/netstate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRetryWakesOnNetworkChange(t *testing.T) {
|
||||||
|
ns := netstate.New()
|
||||||
|
attempts := 0
|
||||||
|
operation := func() error {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
return errors.New("cut by network change")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
ns.Set(false)
|
||||||
|
}()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Minute), ns)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, attempts, "network change must cause one immediate retry")
|
||||||
|
assert.Less(t, time.Since(start), time.Second, "the transition must cut the minute-long sleep short")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryPermanentError(t *testing.T) {
|
||||||
|
sentinel := errors.New("permission denied")
|
||||||
|
operation := func() error {
|
||||||
|
return backoff.Permanent(sentinel)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
|
||||||
|
assert.ErrorIs(t, err, sentinel, "permanent errors must stop retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryNilNetState(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
operation := func() error {
|
||||||
|
attempts++
|
||||||
|
if attempts < 3 {
|
||||||
|
return errors.New("transient")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 3, attempts, "nil network state must preserve timed retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryStops(t *testing.T) {
|
||||||
|
failure := errors.New("still failing")
|
||||||
|
operation := func() error {
|
||||||
|
return failure
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Retry(context.Background(), operation, &backoff.StopBackOff{}, nil)
|
||||||
|
assert.ErrorIs(t, err, failure, "stop backoff must return the operation error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryCtxCancelDuringSleep(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
operation := func() error {
|
||||||
|
return errors.New("failing")
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := Retry(ctx, operation, backoff.NewConstantBackOff(time.Minute), netstate.New())
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, context.Canceled, "context cancellation must stop the retry loop")
|
||||||
|
assert.Less(t, time.Since(start), time.Second, "context cancellation must interrupt backoff sleep")
|
||||||
|
}
|
||||||
@@ -57,10 +57,13 @@ type ICEBind struct {
|
|||||||
endpoints map[netip.Addr]net.Conn
|
endpoints map[netip.Addr]net.Conn
|
||||||
endpointsMu sync.Mutex
|
endpointsMu sync.Mutex
|
||||||
recvChan chan recvMessage
|
recvChan chan recvMessage
|
||||||
// every time when Close() is called (i.e. BindUpdate()) we need to close exit from the receiveRelayed and create a
|
// Close() (i.e. BindUpdate()) closes closedChan to release receiveRelayed,
|
||||||
// new closed channel. With the closedChanMu we can safely close the channel and create a new one
|
// and the following Open() installs a fresh one. closedChanMu guards both
|
||||||
|
// closedChan and closed: readers only ever hold it long enough to copy the
|
||||||
|
// channel, never across a blocking receive, so Open cannot be starved by a
|
||||||
|
// parked receiver.
|
||||||
closedChan chan struct{}
|
closedChan chan struct{}
|
||||||
closedChanMu sync.RWMutex // protect the closeChan recreation from reading from it.
|
closedChanMu sync.RWMutex
|
||||||
closed bool
|
closed bool
|
||||||
activityRecorder *ActivityRecorder
|
activityRecorder *ActivityRecorder
|
||||||
|
|
||||||
@@ -92,24 +95,41 @@ func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ICEBind) Open(uport uint16) ([]wgConn.ReceiveFunc, uint16, error) {
|
func (s *ICEBind) Open(uport uint16) ([]wgConn.ReceiveFunc, uint16, error) {
|
||||||
s.closed = false
|
|
||||||
s.closedChanMu.Lock()
|
s.closedChanMu.Lock()
|
||||||
s.closedChan = make(chan struct{})
|
defer s.closedChanMu.Unlock()
|
||||||
s.closedChanMu.Unlock()
|
|
||||||
|
// Open the underlying bind before touching any state, so a failure leaves
|
||||||
|
// the current generation exactly as it was. Publishing the new generation
|
||||||
|
// first would strand it: StdNetBind rejects an Open while it is already
|
||||||
|
// open, and a Close arriving in that window would mark the bind closed
|
||||||
|
// while this call went on to install live sockets, after which every later
|
||||||
|
// Close returns early and never shuts them down.
|
||||||
fns, port, err := s.StdNetBind.Open(uport)
|
fns, port, err := s.StdNetBind.Open(uport)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release whoever is parked on the outgoing generation before replacing it.
|
||||||
|
// An Open that follows an Open rather than a Close would otherwise leave
|
||||||
|
// them waiting on a channel no later Close can reach.
|
||||||
|
if !s.closed {
|
||||||
|
close(s.closedChan)
|
||||||
|
}
|
||||||
|
s.closed = false
|
||||||
|
s.closedChan = make(chan struct{})
|
||||||
|
|
||||||
fns = append(fns, s.receiveRelayed)
|
fns = append(fns, s.receiveRelayed)
|
||||||
return fns, port, nil
|
return fns, port, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ICEBind) Close() error {
|
func (s *ICEBind) Close() error {
|
||||||
|
s.closedChanMu.Lock()
|
||||||
|
defer s.closedChanMu.Unlock()
|
||||||
|
|
||||||
if s.closed {
|
if s.closed {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.closed = true
|
s.closed = true
|
||||||
|
|
||||||
close(s.closedChan)
|
close(s.closedChan)
|
||||||
|
|
||||||
s.muUDPMux.Lock()
|
s.muUDPMux.Lock()
|
||||||
@@ -121,6 +141,15 @@ func (s *ICEBind) Close() error {
|
|||||||
return s.StdNetBind.Close()
|
return s.StdNetBind.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentClosedChan copies the channel that signals the current Open
|
||||||
|
// generation is closing. Callers select on the copy so the lock is never held
|
||||||
|
// across a blocking receive, which would otherwise stall the next Open.
|
||||||
|
func (s *ICEBind) currentClosedChan() chan struct{} {
|
||||||
|
s.closedChanMu.RLock()
|
||||||
|
defer s.closedChanMu.RUnlock()
|
||||||
|
return s.closedChan
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ICEBind) ActivityRecorder() *ActivityRecorder {
|
func (s *ICEBind) ActivityRecorder() *ActivityRecorder {
|
||||||
return s.activityRecorder
|
return s.activityRecorder
|
||||||
}
|
}
|
||||||
@@ -150,8 +179,10 @@ func (b *ICEBind) RemoveEndpoint(fakeIP netip.Addr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *ICEBind) ReceiveFromEndpoint(ctx context.Context, ep *Endpoint, buf []byte) {
|
func (b *ICEBind) ReceiveFromEndpoint(ctx context.Context, ep *Endpoint, buf []byte) {
|
||||||
|
closedChan := b.currentClosedChan()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-b.closedChan:
|
case <-closedChan:
|
||||||
return
|
return
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
@@ -333,11 +364,10 @@ func (s *ICEBind) parseSTUNMessage(raw []byte) (*stun.Message, error) {
|
|||||||
// receiveRelayed is a receive function that is used to receive packets from the relayed connection and forward to the
|
// receiveRelayed is a receive function that is used to receive packets from the relayed connection and forward to the
|
||||||
// WireGuard. Critical part is do not block if the Closed() has been called.
|
// WireGuard. Critical part is do not block if the Closed() has been called.
|
||||||
func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) {
|
func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) {
|
||||||
c.closedChanMu.RLock()
|
closedChan := c.currentClosedChan()
|
||||||
defer c.closedChanMu.RUnlock()
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-c.closedChan:
|
case <-closedChan:
|
||||||
return 0, net.ErrClosed
|
return 0, net.ErrClosed
|
||||||
case msg, ok := <-c.recvChan:
|
case msg, ok := <-c.recvChan:
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package bind
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
wgConn "golang.zx2c4.com/wireguard/conn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startReceivers runs every receive function the way wireguard-go's device
|
||||||
|
// does: one goroutine per function, all tracked by a single WaitGroup. After
|
||||||
|
// calling Bind.Close, closeBindLocked waits on exactly that WaitGroup while
|
||||||
|
// holding device.net, so a receive function that never returns wedges the
|
||||||
|
// device and every goroutine that needs the same lock.
|
||||||
|
func startReceivers(fns []wgConn.ReceiveFunc) *sync.WaitGroup {
|
||||||
|
wg, _ := startReceiversEntered(fns)
|
||||||
|
return wg
|
||||||
|
}
|
||||||
|
|
||||||
|
// startReceiversEntered also returns a channel closed once every receive
|
||||||
|
// function has been called at least once. A receive function that has been
|
||||||
|
// entered is either inside its blocking receive or about to be, which is a
|
||||||
|
// stronger signal to synchronise on than a bare sleep.
|
||||||
|
func startReceiversEntered(fns []wgConn.ReceiveFunc) (*sync.WaitGroup, <-chan struct{}) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var entered sync.WaitGroup
|
||||||
|
wg.Add(len(fns))
|
||||||
|
entered.Add(len(fns))
|
||||||
|
|
||||||
|
for i := range fns {
|
||||||
|
go func(fn wgConn.ReceiveFunc) {
|
||||||
|
defer wg.Done()
|
||||||
|
buffs := [][]byte{make([]byte, 1500)}
|
||||||
|
sizes := make([]int, 1)
|
||||||
|
eps := make([]wgConn.Endpoint, 1)
|
||||||
|
first := true
|
||||||
|
for {
|
||||||
|
if first {
|
||||||
|
entered.Done()
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
if _, err := fn(buffs, sizes, eps); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(fns[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
allEntered := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
entered.Wait()
|
||||||
|
close(allEntered)
|
||||||
|
}()
|
||||||
|
return &wg, allEntered
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeBounded runs Close off the caller's goroutine so a regression that
|
||||||
|
// wedges it fails the test instead of hanging teardown, and reports whether it
|
||||||
|
// returned in time.
|
||||||
|
func closeBounded(iceBind *ICEBind, timeout time.Duration) bool {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
_ = iceBind.Close()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isClosed reports whether the bind's current generation channel is closed.
|
||||||
|
func isClosed(iceBind *ICEBind) bool {
|
||||||
|
iceBind.closedChanMu.RLock()
|
||||||
|
ch := iceBind.closedChan
|
||||||
|
iceBind.closedChanMu.RUnlock()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiversStopped reports whether every receive function returned before the
|
||||||
|
// timeout, mirroring device.net.stopping.Wait() inside closeBindLocked.
|
||||||
|
func receiversStopped(wg *sync.WaitGroup, timeout time.Duration) bool {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestICEBindCloseReleasesReceivers covers the contract closeBindLocked relies
|
||||||
|
// on: once Close returns, every receive function handed out by Open must stop.
|
||||||
|
func TestICEBindCloseReleasesReceivers(t *testing.T) {
|
||||||
|
iceBind := setupICEBind(t)
|
||||||
|
|
||||||
|
fns, _, err := iceBind.Open(0)
|
||||||
|
require.NoError(t, err, "opening the bind must succeed")
|
||||||
|
|
||||||
|
wg := startReceivers(fns)
|
||||||
|
require.NoError(t, iceBind.Close())
|
||||||
|
|
||||||
|
require.True(t, receiversStopped(wg, 5*time.Second),
|
||||||
|
"every receive function must return once Close returns")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestICEBindOpenDoesNotBlockOnParkedReceiver reproduces the deadlock that
|
||||||
|
// wedges interface creation.
|
||||||
|
//
|
||||||
|
// receiveRelayed used to hold closedChanMu for the whole of its blocking
|
||||||
|
// select, so a parked receiver kept the read lock indefinitely. Open takes the
|
||||||
|
// same mutex for writing to install a fresh closedChan, so it could never
|
||||||
|
// acquire it while a receiver was parked. wireguard-go reaches Open from
|
||||||
|
// Device.IpcSet and Device.Up with device.net held, so the stall takes the
|
||||||
|
// device's lock with it and every other device goroutine queues behind it.
|
||||||
|
//
|
||||||
|
// Failing here means Open never returned.
|
||||||
|
func TestICEBindOpenDoesNotBlockOnParkedReceiver(t *testing.T) {
|
||||||
|
iceBind := setupICEBind(t)
|
||||||
|
|
||||||
|
fns, _, err := iceBind.Open(0)
|
||||||
|
require.NoError(t, err, "the first Open must succeed")
|
||||||
|
|
||||||
|
wg, entered := startReceiversEntered(fns)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
// Both bounded: a regression that wedges Close must surface as the
|
||||||
|
// assertion below, not as a hung teardown.
|
||||||
|
if !closeBounded(iceBind, 5*time.Second) {
|
||||||
|
t.Error("Close did not return during teardown; the bind lifecycle is wedged even though the assertion above passed")
|
||||||
|
}
|
||||||
|
if !receiversStopped(wg, 5*time.Second) {
|
||||||
|
t.Error("receive functions were still running after teardown Close, which is what closeBindLocked blocks on")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("receive functions never started")
|
||||||
|
}
|
||||||
|
// Entered is not yet parked, so still allow the blocking receive to be
|
||||||
|
// reached. Parking takes microseconds; this margin is six orders larger.
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
reopened := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
// The error is irrelevant; StdNetBind rejects a second Open. What
|
||||||
|
// matters is that the call returns at all.
|
||||||
|
_, _, _ = iceBind.Open(0)
|
||||||
|
close(reopened)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-reopened:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("Open blocked while a receive function was parked; wireguard-go makes this call with device.net held, which is what stalls interface creation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestICEBindConcurrentOpenClose exercises Open and Close from separate
|
||||||
|
// goroutines, the way Device.IpcSet and Device.Up reach the bind, and is meant
|
||||||
|
// to be run under -race.
|
||||||
|
//
|
||||||
|
// closed and closedChan must be updated together. When they were not, Close
|
||||||
|
// could observe a stale closed and either skip close(closedChan) and
|
||||||
|
// StdNetBind.Close entirely, leaving the receive functions running, or race a
|
||||||
|
// second Close and close the same channel twice.
|
||||||
|
//
|
||||||
|
// Receive functions are deliberately not started here: this test is about the
|
||||||
|
// shared state, and parking them would turn a race report into a hang.
|
||||||
|
func TestICEBindConcurrentOpenClose(t *testing.T) {
|
||||||
|
iceBind := setupICEBind(t)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
// Release both loops together so the calls genuinely interleave rather
|
||||||
|
// than depending on goroutine start order.
|
||||||
|
start := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
_, _, _ = iceBind.Open(0)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
_ = iceBind.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
require.NoError(t, iceBind.Close())
|
||||||
|
require.True(t, isClosed(iceBind), "the final Close must leave the current generation channel closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestICEBindCloseReleasesReceiversUnderConcurrentClose runs full Open, receive,
|
||||||
|
// Close cycles with a second Close and an Open racing the first Close. Any
|
||||||
|
// iteration where the receive functions outlive Close, or where the surviving
|
||||||
|
// generation channel is left open, is the state closeBindLocked deadlocks on.
|
||||||
|
func TestICEBindCloseReleasesReceiversUnderConcurrentClose(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("stress test")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
iceBind := setupICEBind(t)
|
||||||
|
|
||||||
|
fns, _, err := iceBind.Open(0)
|
||||||
|
require.NoError(t, err, "iteration %d: opening the bind must succeed", i)
|
||||||
|
wg := startReceivers(fns)
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
var racers sync.WaitGroup
|
||||||
|
racers.Add(3)
|
||||||
|
for c := 0; c < 2; c++ {
|
||||||
|
go func() {
|
||||||
|
defer racers.Done()
|
||||||
|
<-start
|
||||||
|
_ = iceBind.Close()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// An Open overlapping the Closes is what produces a generation whose
|
||||||
|
// channel outlives the flag saying the bind is closed.
|
||||||
|
go func() {
|
||||||
|
defer racers.Done()
|
||||||
|
<-start
|
||||||
|
_, _, _ = iceBind.Open(0)
|
||||||
|
}()
|
||||||
|
close(start)
|
||||||
|
racers.Wait()
|
||||||
|
|
||||||
|
// Settle on a closed bind whatever order the racers landed in.
|
||||||
|
_ = iceBind.Close()
|
||||||
|
|
||||||
|
if !receiversStopped(wg, 5*time.Second) {
|
||||||
|
t.Fatalf("iteration %d: receive functions still running after Close; closeBindLocked would block here on device.net.stopping.Wait", i)
|
||||||
|
}
|
||||||
|
if !isClosed(iceBind) {
|
||||||
|
t.Fatalf("iteration %d: Close returned with the current generation channel still open, so nothing will ever release its receivers", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -502,7 +502,7 @@ func toBytes(s string) (int64, error) {
|
|||||||
|
|
||||||
func getFwmark() int {
|
func getFwmark() int {
|
||||||
if nbnet.AdvancedRouting() && runtime.GOOS == "linux" {
|
if nbnet.AdvancedRouting() && runtime.GOOS == "linux" {
|
||||||
return nbnet.ControlPlaneMark
|
return int(nbnet.ControlPlaneMark)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"go.uber.org/mock/gomock"
|
||||||
"github.com/google/gopacket"
|
"github.com/google/gopacket"
|
||||||
"github.com/google/gopacket/layers"
|
"github.com/google/gopacket/layers"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
reflect "reflect"
|
reflect "reflect"
|
||||||
|
|
||||||
gomock "github.com/golang/mock/gomock"
|
gomock "go.uber.org/mock/gomock"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockPacketFilter is a mock of PacketFilter interface.
|
// MockPacketFilter is a mock of PacketFilter interface.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
os "os"
|
os "os"
|
||||||
reflect "reflect"
|
reflect "reflect"
|
||||||
|
|
||||||
gomock "github.com/golang/mock/gomock"
|
gomock "go.uber.org/mock/gomock"
|
||||||
tun "golang.zx2c4.com/wireguard/tun"
|
tun "golang.zx2c4.com/wireguard/tun"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -53,15 +53,15 @@ func NewProxyBind(bind Bind, mtu uint16) *ProxyBind {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddTurnConn adds a new connection to the bind.
|
// AddRelayedConn adds a new connection to the bind.
|
||||||
// endpoint is the NetBird address of the remote peer. The SetEndpoint return with the address what will be used in the
|
// endpoint is the NetBird address of the remote peer. The SetEndpoint return with the address what will be used in the
|
||||||
// WireGuard configuration.
|
// WireGuard configuration.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - ctx: Context is used for proxyToLocal to avoid unnecessary error messages
|
// - ctx: Context is used for proxyToLocal to avoid unnecessary error messages
|
||||||
// - nbAddr: The NetBird UDP address of the remote peer, it required to generate fake address
|
// - nbAddr: The NetBird UDP address of the remote peer, it required to generate fake address
|
||||||
// - remoteConn: The established TURN connection to the remote peer
|
// - remoteConn: The established relayed connection to the remote peer
|
||||||
func (p *ProxyBind) AddTurnConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error {
|
func (p *ProxyBind) AddRelayedConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error {
|
||||||
fakeNetIP, err := fakeAddress(nbAddr)
|
fakeNetIP, err := fakeAddress(nbAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ type WGEBPFProxy struct {
|
|||||||
proxyPort int
|
proxyPort int
|
||||||
mtu uint16
|
mtu uint16
|
||||||
|
|
||||||
ebpfManager ebpfMgr.Manager
|
ebpfManager ebpfMgr.Manager
|
||||||
turnConnStore map[uint16]net.Conn
|
relayedConnStore map[uint16]net.Conn
|
||||||
turnConnMutex sync.Mutex
|
relayedConnMutex sync.Mutex
|
||||||
|
|
||||||
lastUsedPort uint16
|
lastUsedPort uint16
|
||||||
rawConnIPv4 net.PacketConn
|
rawConnIPv4 net.PacketConn
|
||||||
@@ -50,7 +50,7 @@ func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy {
|
|||||||
localWGListenPort: wgPort,
|
localWGListenPort: wgPort,
|
||||||
mtu: mtu,
|
mtu: mtu,
|
||||||
ebpfManager: ebpf.GetEbpfManagerInstance(),
|
ebpfManager: ebpf.GetEbpfManagerInstance(),
|
||||||
turnConnStore: make(map[uint16]net.Conn),
|
relayedConnStore: make(map[uint16]net.Conn),
|
||||||
}
|
}
|
||||||
return wgProxy
|
return wgProxy
|
||||||
}
|
}
|
||||||
@@ -110,14 +110,14 @@ func (p *WGEBPFProxy) Listen() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddTurnConn add new turn connection for the proxy
|
// AddRelayedConn add new relayed connection for the proxy
|
||||||
func (p *WGEBPFProxy) AddTurnConn(turnConn net.Conn) (*net.UDPAddr, error) {
|
func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) {
|
||||||
wgEndpointPort, err := p.storeTurnConn(turnConn)
|
wgEndpointPort, err := p.storeRelayedConn(relayedConn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("turn conn added to wg proxy store: %s, endpoint port: :%d", turnConn.RemoteAddr(), wgEndpointPort)
|
log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort)
|
||||||
|
|
||||||
wgEndpoint := &net.UDPAddr{
|
wgEndpoint := &net.UDPAddr{
|
||||||
IP: net.ParseIP(loopbackAddr),
|
IP: net.ParseIP(loopbackAddr),
|
||||||
@@ -186,48 +186,48 @@ func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error {
|
|||||||
return fmt.Errorf("failed to read UDP packet from WG: %w", err)
|
return fmt.Errorf("failed to read UDP packet from WG: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.turnConnMutex.Lock()
|
p.relayedConnMutex.Lock()
|
||||||
conn, ok := p.turnConnStore[uint16(addr.Port)]
|
conn, ok := p.relayedConnStore[uint16(addr.Port)]
|
||||||
p.turnConnMutex.Unlock()
|
p.relayedConnMutex.Unlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
if p.ctx.Err() == nil {
|
if p.ctx.Err() == nil {
|
||||||
log.Debugf("turn conn not found by port because conn already has been closed: %d", addr.Port)
|
log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := conn.Write(buf[:n]); err != nil {
|
if _, err := conn.Write(buf[:n]); err != nil {
|
||||||
return fmt.Errorf("failed to forward local WG packet (%d) to remote turn conn: %w", addr.Port, err)
|
return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *WGEBPFProxy) storeTurnConn(turnConn net.Conn) (uint16, error) {
|
func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) {
|
||||||
p.turnConnMutex.Lock()
|
p.relayedConnMutex.Lock()
|
||||||
defer p.turnConnMutex.Unlock()
|
defer p.relayedConnMutex.Unlock()
|
||||||
|
|
||||||
np, err := p.nextFreePort()
|
np, err := p.nextFreePort()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return np, err
|
return np, err
|
||||||
}
|
}
|
||||||
p.turnConnStore[np] = turnConn
|
p.relayedConnStore[np] = relayedConn
|
||||||
return np, nil
|
return np, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *WGEBPFProxy) removeTurnConn(turnConnID uint16) {
|
func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) {
|
||||||
p.turnConnMutex.Lock()
|
p.relayedConnMutex.Lock()
|
||||||
defer p.turnConnMutex.Unlock()
|
defer p.relayedConnMutex.Unlock()
|
||||||
|
|
||||||
_, ok := p.turnConnStore[turnConnID]
|
_, ok := p.relayedConnStore[relayedConnID]
|
||||||
if ok {
|
if ok {
|
||||||
log.Debugf("remove turn conn from store by port: %d", turnConnID)
|
log.Debugf("remove relayed conn from store by port: %d", relayedConnID)
|
||||||
}
|
}
|
||||||
delete(p.turnConnStore, turnConnID)
|
delete(p.relayedConnStore, relayedConnID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *WGEBPFProxy) nextFreePort() (uint16, error) {
|
func (p *WGEBPFProxy) nextFreePort() (uint16, error) {
|
||||||
if len(p.turnConnStore) == 65535 {
|
if len(p.relayedConnStore) == 65535 {
|
||||||
return 0, fmt.Errorf("reached maximum turn connection numbers")
|
return 0, fmt.Errorf("reached maximum relayed connection numbers")
|
||||||
}
|
}
|
||||||
generatePort:
|
generatePort:
|
||||||
if p.lastUsedPort == 65535 {
|
if p.lastUsedPort == 65535 {
|
||||||
@@ -236,7 +236,7 @@ generatePort:
|
|||||||
p.lastUsedPort++
|
p.lastUsedPort++
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := p.turnConnStore[p.lastUsedPort]; ok {
|
if _, ok := p.relayedConnStore[p.lastUsedPort]; ok {
|
||||||
goto generatePort
|
goto generatePort
|
||||||
}
|
}
|
||||||
return p.lastUsedPort, nil
|
return p.lastUsedPort, nil
|
||||||
|
|||||||
@@ -9,32 +9,32 @@ import (
|
|||||||
func TestWGEBPFProxy_connStore(t *testing.T) {
|
func TestWGEBPFProxy_connStore(t *testing.T) {
|
||||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||||
|
|
||||||
p, _ := wgProxy.storeTurnConn(nil)
|
p, _ := wgProxy.storeRelayedConn(nil)
|
||||||
if p != 1 {
|
if p != 1 {
|
||||||
t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort)
|
t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
numOfConns := 10
|
numOfConns := 10
|
||||||
for i := 0; i < numOfConns; i++ {
|
for i := 0; i < numOfConns; i++ {
|
||||||
p, _ = wgProxy.storeTurnConn(nil)
|
p, _ = wgProxy.storeRelayedConn(nil)
|
||||||
}
|
}
|
||||||
if p != uint16(numOfConns)+1 {
|
if p != uint16(numOfConns)+1 {
|
||||||
t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1)
|
t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1)
|
||||||
}
|
}
|
||||||
if len(wgProxy.turnConnStore) != numOfConns+1 {
|
if len(wgProxy.relayedConnStore) != numOfConns+1 {
|
||||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), numOfConns+1)
|
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) {
|
func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) {
|
||||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||||
|
|
||||||
_, _ = wgProxy.storeTurnConn(nil)
|
_, _ = wgProxy.storeRelayedConn(nil)
|
||||||
wgProxy.lastUsedPort = 65535
|
wgProxy.lastUsedPort = 65535
|
||||||
p, _ := wgProxy.storeTurnConn(nil)
|
p, _ := wgProxy.storeRelayedConn(nil)
|
||||||
|
|
||||||
if len(wgProxy.turnConnStore) != 2 {
|
if len(wgProxy.relayedConnStore) != 2 {
|
||||||
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), 2)
|
t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
if p != 2 {
|
if p != 2 {
|
||||||
@@ -46,11 +46,11 @@ func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) {
|
|||||||
wgProxy := NewWGEBPFProxy(1, 1280)
|
wgProxy := NewWGEBPFProxy(1, 1280)
|
||||||
|
|
||||||
for i := 0; i < 65535; i++ {
|
for i := 0; i < 65535; i++ {
|
||||||
_, _ = wgProxy.storeTurnConn(nil)
|
_, _ = wgProxy.storeRelayedConn(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := wgProxy.storeTurnConn(nil)
|
_, err := wgProxy.storeRelayedConn(nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("invalid turn conn store calculation")
|
t.Errorf("invalid relayed conn store calculation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,10 +121,10 @@ func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProxyWrapper) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||||
addr, err := p.wgeBPFProxy.AddTurnConn(remoteConn)
|
addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add turn conn: %w", err)
|
return fmt.Errorf("add relayed conn: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr)
|
headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr)
|
||||||
@@ -252,7 +252,7 @@ func (p *ProxyWrapper) CloseConn() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
|
func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
|
||||||
defer p.wgeBPFProxy.removeTurnConn(uint16(p.wgRelayedEndpointAddr.Port))
|
defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port))
|
||||||
|
|
||||||
buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead)
|
buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead)
|
||||||
for {
|
for {
|
||||||
@@ -273,7 +273,7 @@ func (p *ProxyWrapper) proxyToLocal(ctx context.Context) {
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Errorf("failed to write out turn pkg to local conn: %v", err)
|
log.Errorf("failed to write out relayed pkg to local conn: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,7 +286,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err
|
|||||||
}
|
}
|
||||||
p.closeListener.Notify()
|
p.closeListener.Notify()
|
||||||
if !errors.Is(err, io.EOF) {
|
if !errors.Is(err, io.EOF) {
|
||||||
log.Errorf("failed to read from turn conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err)
|
log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err)
|
||||||
}
|
}
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
// Proxy is a transfer layer between the relayed connection and the WireGuard
|
// Proxy is a transfer layer between the relayed connection and the WireGuard
|
||||||
type Proxy interface {
|
type Proxy interface {
|
||||||
AddTurnConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error
|
AddRelayedConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error
|
||||||
EndpointAddr() *net.UDPAddr // EndpointAddr returns the address of the WireGuard peer endpoint
|
EndpointAddr() *net.UDPAddr // EndpointAddr returns the address of the WireGuard peer endpoint
|
||||||
Work() // Work start or resume the proxy
|
Work() // Work start or resume the proxy
|
||||||
Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works.
|
Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works.
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func TestProxyCloseByRemoteConn(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
addr, _ := net.ResolveUDPAddr("udp", "100.108.135.221:51892")
|
addr, _ := net.ResolveUDPAddr("udp", "100.108.135.221:51892")
|
||||||
relayedConn := newMockConn()
|
relayedConn := newMockConn()
|
||||||
err := tt.proxy.AddTurnConn(ctx, addr, relayedConn)
|
err := tt.proxy.AddRelayedConn(ctx, addr, relayedConn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("error: %v", err)
|
t.Errorf("error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ func redirectTraffic(t *testing.T, proxy Proxy, wgPort int, endPointAddr *net.UD
|
|||||||
_ = relayedServer.Close()
|
_ = relayedServer.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := proxy.AddTurnConn(context.Background(), endPointAddr, relayedConn); err != nil {
|
if err := proxy.AddRelayedConn(context.Background(), endPointAddr, relayedConn); err != nil {
|
||||||
t.Errorf("error: %v", err)
|
t.Errorf("error: %v", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import (
|
|||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
nbnet "github.com/netbirdio/netbird/client/net"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets
|
// PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets
|
||||||
@@ -60,14 +58,12 @@ func prepareSenderRawSocket(family int, isIPv4 bool) (net.PacketConn, error) {
|
|||||||
return nil, fmt.Errorf("binding to lo interface failed: %w", err)
|
return nil, fmt.Errorf("binding to lo interface failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the fwmark on the socket.
|
// The socket is bound to lo and only ever sends to the local WireGuard
|
||||||
err = nbnet.SetSocketOpt(fd)
|
// instance, a destination the local routing table resolves without help, so
|
||||||
if err != nil {
|
// it carries no fwmark. Staying unmarked also keeps these packets out of
|
||||||
if closeErr := syscall.Close(fd); closeErr != nil {
|
// third-party NAT rules that match on marks: such a rule rewriting the
|
||||||
log.Warnf("failed to close raw socket fd: %v", closeErr)
|
// source would make WireGuard adopt the rewritten address as the peer
|
||||||
}
|
// endpoint.
|
||||||
return nil, fmt.Errorf("setting fwmark failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert the file descriptor to a PacketConn.
|
// Convert the file descriptor to a PacketConn.
|
||||||
file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd))
|
file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd))
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//go:build linux && !android && privileged
|
||||||
|
|
||||||
|
package rawsocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
nbnet "github.com/netbirdio/netbird/client/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The sender sockets must stay unmarked: a NAT rule matching on fwmark that
|
||||||
|
// rewrites the source of an injected packet makes WireGuard adopt the rewritten
|
||||||
|
// address as the peer endpoint.
|
||||||
|
func TestSenderRawSocketsCarryNoFwmark(t *testing.T) {
|
||||||
|
// the mark is only ever applied when advanced routing is available, so
|
||||||
|
// without it the assertion below would hold for the wrong reason
|
||||||
|
nbnet.Init()
|
||||||
|
if !nbnet.AdvancedRouting() {
|
||||||
|
t.Skip("advanced routing unsupported, the sockets carry no mark either way")
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prepare func() (net.PacketConn, error)
|
||||||
|
// the proxy treats the IPv6 socket as optional, so a host without IPv6
|
||||||
|
// is a reason to skip rather than to fail
|
||||||
|
optional bool
|
||||||
|
}{
|
||||||
|
{name: "IPv4", prepare: PrepareSenderRawSocketIPv4},
|
||||||
|
{name: "IPv6", prepare: PrepareSenderRawSocketIPv6, optional: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
conn, err := tc.prepare()
|
||||||
|
if err != nil {
|
||||||
|
if tc.optional {
|
||||||
|
t.Skipf("prepare raw socket: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatalf("prepare raw socket: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := conn.Close(); err != nil {
|
||||||
|
t.Logf("close raw socket: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
syscallConn, ok := conn.(syscall.Conn)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("raw socket %T does not expose a syscall conn", conn)
|
||||||
|
}
|
||||||
|
raw, err := syscallConn.SyscallConn()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("syscall conn: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mark int
|
||||||
|
var markErr error
|
||||||
|
if err := raw.Control(func(fd uintptr) {
|
||||||
|
mark, markErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("control: %v", err)
|
||||||
|
}
|
||||||
|
if markErr != nil {
|
||||||
|
t.Fatalf("get SO_MARK: %v", markErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mark != 0 {
|
||||||
|
t.Errorf("SO_MARK = %#x, want 0", mark)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,9 +119,9 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *
|
|||||||
}
|
}
|
||||||
defer relayConn.Close()
|
defer relayConn.Close()
|
||||||
|
|
||||||
// Add TURN connection to proxy
|
// Add relayed connection to proxy
|
||||||
if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil {
|
if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil {
|
||||||
t.Fatalf("failed to add TURN connection: %v", err)
|
t.Fatalf("failed to add relayed connection: %v", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := proxy.CloseConn(); err != nil {
|
if err := proxy.CloseConn(); err != nil {
|
||||||
@@ -304,8 +304,8 @@ func TestRedirectAs_Multiple_Switches(t *testing.T) {
|
|||||||
Port: 38746,
|
Port: 38746,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil {
|
if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil {
|
||||||
t.Fatalf("failed to add TURN connection: %v", err)
|
t.Fatalf("failed to add relayed connection: %v", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := proxy.CloseConn(); err != nil {
|
if err := proxy.CloseConn(); err != nil {
|
||||||
|
|||||||
@@ -51,12 +51,12 @@ func NewWGUDPProxy(wgPort int, mtu uint16) *WGUDPProxy {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddTurnConn
|
// AddRelayedConn dials the local WireGuard port and stores the relayed connection.
|
||||||
// The provided Context must be non-nil. If the context expires before
|
// The provided Context must be non-nil. If the context expires before
|
||||||
// the connection is complete, an error is returned. Once successfully
|
// the connection is complete, an error is returned. Once successfully
|
||||||
// connected, any expiration of the context will not affect the
|
// connected, any expiration of the context will not affect the
|
||||||
// connection.
|
// connection.
|
||||||
func (p *WGUDPProxy) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
func (p *WGUDPProxy) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error {
|
||||||
dialer := net.Dialer{}
|
dialer := net.Dialer{}
|
||||||
localConn, err := dialer.DialContext(ctx, "udp", fmt.Sprintf(":%d", p.localWGListenPort))
|
localConn, err := dialer.DialContext(ctx, "udp", fmt.Sprintf(":%d", p.localWGListenPort))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -116,11 +116,11 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
|
|||||||
// firewall state, so an identical hash means an identical resulting ruleset.
|
// firewall state, so an identical hash means an identical resulting ruleset.
|
||||||
func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) {
|
func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) {
|
||||||
return hashstructure.Hash(struct {
|
return hashstructure.Hash(struct {
|
||||||
PeerRules []*mgmProto.FirewallRule
|
PeerRules []*mgmProto.FirewallRule
|
||||||
PeerRulesIsEmpty bool
|
PeerRulesIsEmpty bool
|
||||||
RouteRules []*mgmProto.RouteFirewallRule
|
RouteRules []*mgmProto.RouteFirewallRule
|
||||||
RouteRulesIsEmpty bool
|
RouteRulesIsEmpty bool
|
||||||
DNSRouteFeatureFlag bool
|
DNSRouteFeatureFlag bool
|
||||||
}{
|
}{
|
||||||
PeerRules: networkMap.GetFirewallRules(),
|
PeerRules: networkMap.GetFirewallRules(),
|
||||||
PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(),
|
PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(),
|
||||||
@@ -144,13 +144,13 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) {
|
|||||||
log.Warn("this peer is connected to a NetBird Management service with an older version. Allowing all traffic from connected peers")
|
log.Warn("this peer is connected to a NetBird Management service with an older version. Allowing all traffic from connected peers")
|
||||||
rules = append(rules,
|
rules = append(rules,
|
||||||
&mgmProto.FirewallRule{
|
&mgmProto.FirewallRule{
|
||||||
PeerIP: "0.0.0.0",
|
PeerIP: "0.0.0.0", //nolint:staticcheck
|
||||||
Direction: mgmProto.RuleDirection_IN,
|
Direction: mgmProto.RuleDirection_IN,
|
||||||
Action: mgmProto.RuleAction_ACCEPT,
|
Action: mgmProto.RuleAction_ACCEPT,
|
||||||
Protocol: mgmProto.RuleProtocol_ALL,
|
Protocol: mgmProto.RuleProtocol_ALL,
|
||||||
},
|
},
|
||||||
&mgmProto.FirewallRule{
|
&mgmProto.FirewallRule{
|
||||||
PeerIP: "0.0.0.0",
|
PeerIP: "0.0.0.0", //nolint:staticcheck
|
||||||
Direction: mgmProto.RuleDirection_OUT,
|
Direction: mgmProto.RuleDirection_OUT,
|
||||||
Action: mgmProto.RuleAction_ACCEPT,
|
Action: mgmProto.RuleAction_ACCEPT,
|
||||||
Protocol: mgmProto.RuleProtocol_ALL,
|
Protocol: mgmProto.RuleProtocol_ALL,
|
||||||
@@ -407,7 +407,6 @@ func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) st
|
|||||||
return fmt.Sprintf("%v:%v:%v:%s:%v", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port, rule.PortInfo)
|
return fmt.Sprintf("%v:%v:%v:%s:%v", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port, rule.PortInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// extractRuleIP extracts the peer IP from a firewall rule.
|
// extractRuleIP extracts the peer IP from a firewall rule.
|
||||||
// If sourcePrefixes is populated (new management), decode the first entry and use its address.
|
// If sourcePrefixes is populated (new management), decode the first entry and use its address.
|
||||||
// Otherwise fall back to the deprecated PeerIP string field (old management).
|
// Otherwise fall back to the deprecated PeerIP string field (old management).
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/firewall"
|
"github.com/netbirdio/netbird/client/firewall"
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
@@ -87,7 +87,7 @@ func TestDefaultManager(t *testing.T) {
|
|||||||
networkMap.FirewallRules = append(
|
networkMap.FirewallRules = append(
|
||||||
networkMap.FirewallRules,
|
networkMap.FirewallRules,
|
||||||
&mgmProto.FirewallRule{
|
&mgmProto.FirewallRule{
|
||||||
PeerIP: "10.93.0.3",
|
PeerIP: "10.93.0.3", //nolint:staticcheck
|
||||||
Direction: mgmProto.RuleDirection_IN,
|
Direction: mgmProto.RuleDirection_IN,
|
||||||
Action: mgmProto.RuleAction_DROP,
|
Action: mgmProto.RuleAction_DROP,
|
||||||
Protocol: mgmProto.RuleProtocol_ICMP,
|
Protocol: mgmProto.RuleProtocol_ICMP,
|
||||||
@@ -556,12 +556,12 @@ func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) {
|
|||||||
|
|
||||||
func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap {
|
func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap {
|
||||||
nm := &mgmProto.NetworkMap{
|
nm := &mgmProto.NetworkMap{
|
||||||
FirewallRulesIsEmpty: peerRules == 0,
|
FirewallRulesIsEmpty: peerRules == 0,
|
||||||
RoutesFirewallRulesIsEmpty: routeRules == 0,
|
RoutesFirewallRulesIsEmpty: routeRules == 0,
|
||||||
}
|
}
|
||||||
for i := range peerRules {
|
for i := range peerRules {
|
||||||
nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{
|
nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{
|
||||||
PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff),
|
PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff), //nolint:staticcheck
|
||||||
Direction: mgmProto.RuleDirection_IN,
|
Direction: mgmProto.RuleDirection_IN,
|
||||||
Action: mgmProto.RuleAction_ACCEPT,
|
Action: mgmProto.RuleAction_ACCEPT,
|
||||||
Protocol: mgmProto.RuleProtocol_TCP,
|
Protocol: mgmProto.RuleProtocol_TCP,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ package mocks
|
|||||||
import (
|
import (
|
||||||
reflect "reflect"
|
reflect "reflect"
|
||||||
|
|
||||||
gomock "github.com/golang/mock/gomock"
|
gomock "go.uber.org/mock/gomock"
|
||||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface/device"
|
"github.com/netbirdio/netbird/client/iface/device"
|
||||||
|
|||||||
@@ -138,26 +138,37 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) {
|
|||||||
|
|
||||||
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection
|
// GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection
|
||||||
// This avoids creating a new connection to the management server
|
// This avoids creating a new connection to the management server
|
||||||
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool) (OAuthFlow, error) {
|
func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) {
|
||||||
var flow OAuthFlow
|
var flow OAuthFlow
|
||||||
var err error
|
|
||||||
|
|
||||||
err = a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
err := a.withRetry(ctx, func(client *mgm.GrpcClient) error {
|
||||||
if forceDeviceAuth {
|
if forceDeviceAuth {
|
||||||
flow, err = a.getDeviceFlow(client)
|
deviceFlow, err := a.getDeviceFlow(client)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deviceFlow.SetLoginHint(hint)
|
||||||
|
flow = deviceFlow
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try PKCE flow first
|
// Try PKCE flow first
|
||||||
flow, err = a.getPKCEFlow(client)
|
pkceFlow, err := a.getPKCEFlow(client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If PKCE not supported, try Device flow
|
// If PKCE not supported, try Device flow
|
||||||
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
|
if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) {
|
||||||
flow, err = a.getDeviceFlow(client)
|
deviceFlow, err := a.getDeviceFlow(client)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deviceFlow.SetLoginHint(hint)
|
||||||
|
flow = deviceFlow
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
pkceFlow.SetLoginHint(hint)
|
||||||
|
flow = pkceFlow
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -357,6 +368,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
|||||||
a.config.EnableSSHLocalPortForwarding,
|
a.config.EnableSSHLocalPortForwarding,
|
||||||
a.config.EnableSSHRemotePortForwarding,
|
a.config.EnableSSHRemotePortForwarding,
|
||||||
a.config.DisableSSHAuth,
|
a.config.DisableSSHAuth,
|
||||||
|
a.config.RemoteJobsAllowed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,16 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
|||||||
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
|
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same as the PKCE flow: the account the token belongs to is what
|
||||||
|
// callers store to send back as the login_hint. Without it a client
|
||||||
|
// driven through the device flow — Android TV and tvOS — never binds
|
||||||
|
// an account to its profile and every later login goes out blind.
|
||||||
|
if email, err := parseEmailFromIDToken(tokenInfo.IDToken); err != nil {
|
||||||
|
log.Warnf("failed to parse email from ID token: %v", err)
|
||||||
|
} else {
|
||||||
|
tokenInfo.Email = email
|
||||||
|
}
|
||||||
|
|
||||||
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
|
log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second))
|
||||||
return tokenInfo, err
|
return tokenInfo, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,9 +97,7 @@ func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config
|
|||||||
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
|
return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hint != "" {
|
pkceFlowInfo.SetLoginHint(hint)
|
||||||
pkceFlowInfo.SetLoginHint(hint)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pkceFlowInfo, nil
|
return pkceFlowInfo, nil
|
||||||
}
|
}
|
||||||
@@ -127,9 +125,7 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if hint != "" {
|
deviceFlowInfo.SetLoginHint(hint)
|
||||||
deviceFlowInfo.SetLoginHint(hint)
|
|
||||||
}
|
|
||||||
|
|
||||||
return deviceFlowInfo, nil
|
return deviceFlowInfo, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-104
@@ -2,6 +2,7 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// lazyForce is the resolved local decision for lazy connections, layered above the
|
// lazyForce is the resolved local decision for lazy connections, layered above the
|
||||||
@@ -37,11 +39,13 @@ const (
|
|||||||
// The only exception is ActivatePeer, which is safe for concurrent use so the
|
// 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.
|
// DNS warm-up path can call it without contending on the engine mutex.
|
||||||
type ConnMgr struct {
|
type ConnMgr struct {
|
||||||
peerStore *peerstore.Store
|
peerStore *peerstore.Store
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
iface lazyconn.WGIface
|
iface lazyconn.WGIface
|
||||||
force lazyForce
|
force lazyForce
|
||||||
rosenpassEnabled bool
|
// remoteLazyEnabled caches the account-wide lazy feature flag from management.
|
||||||
|
// It is the default for peers that do not carry a per-peer lazy hint.
|
||||||
|
remoteLazyEnabled bool
|
||||||
|
|
||||||
lazyConnMgr *manager.Manager
|
lazyConnMgr *manager.Manager
|
||||||
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
|
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
|
||||||
@@ -53,6 +57,10 @@ type ConnMgr struct {
|
|||||||
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
|
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
|
||||||
reconcileRoutedIPs func(peerKey string) error
|
reconcileRoutedIPs func(peerKey string) error
|
||||||
|
|
||||||
|
// appliedExcludeList is the exclude set last handed to the lazy manager, kept so an
|
||||||
|
// unchanged set on the next sync skips the O(n) reconciliation.
|
||||||
|
appliedExcludeList map[string]bool
|
||||||
|
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
lazyCtx context.Context
|
lazyCtx context.Context
|
||||||
lazyCtxCancel context.CancelFunc
|
lazyCtxCancel context.CancelFunc
|
||||||
@@ -66,78 +74,59 @@ func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
|
|||||||
|
|
||||||
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
|
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
|
||||||
e := &ConnMgr{
|
e := &ConnMgr{
|
||||||
peerStore: peerStore,
|
peerStore: peerStore,
|
||||||
statusRecorder: statusRecorder,
|
statusRecorder: statusRecorder,
|
||||||
iface: iface,
|
iface: iface,
|
||||||
force: resolveLazyForce(engineConfig.LazyConnection),
|
force: resolveLazyForce(engineConfig.LazyConnection),
|
||||||
rosenpassEnabled: engineConfig.RosenpassEnabled,
|
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initializes the connection manager. It starts the lazy connection manager when a
|
// Start initializes the connection manager. The lazy connection manager always runs so that
|
||||||
// local override forces it on; with no local override it waits for the management feature flag.
|
// per-peer lazy defaults (e.g. proxy peers) work even when the account flag is off; the
|
||||||
|
// account flag and the local override decide the default lazy state per peer (see
|
||||||
|
// PeerLazyDefault). Rosenpass peers stay lazy-capable too: their connections just never idle
|
||||||
|
// on their own, since rosenpass rekey traffic keeps them active.
|
||||||
func (e *ConnMgr) Start(ctx context.Context) {
|
func (e *ConnMgr) Start(ctx context.Context) {
|
||||||
if e.lazyConnMgr != nil {
|
if e.lazyConnMgr != nil {
|
||||||
log.Errorf("lazy connection manager is already started")
|
log.Errorf("lazy connection manager is already started")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch e.force {
|
|
||||||
case lazyForceOff:
|
|
||||||
log.Infof("lazy connection manager is disabled by local override (%s or MDM policy)", lazyconn.EnvLazyConn)
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return
|
|
||||||
case lazyForceNone:
|
|
||||||
log.Infof("lazy connection manager is managed by the management feature flag")
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.rosenpassEnabled {
|
|
||||||
log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started")
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.initLazyManager(ctx)
|
e.initLazyManager(ctx)
|
||||||
e.statusRecorder.UpdateLazyConnection(true)
|
e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatedRemoteFeatureFlag is called when the remote feature flag is updated.
|
// UpdatedRemoteFeatureFlag caches the account-wide lazy feature flag. The manager itself is
|
||||||
// If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again.
|
// not started or stopped here; the per-sync exclude-list reconciliation moves normal peers
|
||||||
// If disabled, then it closes the lazy connection manager and open the connections to all peers.
|
// between the lazy and always-active sets when the flag flips.
|
||||||
func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error {
|
func (e *ConnMgr) UpdatedRemoteFeatureFlag(_ context.Context, enabled bool) error {
|
||||||
// a local override (NB_LAZY_CONN or local config) takes precedence over management
|
e.remoteLazyEnabled = enabled
|
||||||
if e.force != lazyForceNone {
|
if e.isStartedWithLazyMgr() {
|
||||||
return nil
|
e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerLazyDefault reports whether a peer should be lazy. The local override
|
||||||
|
// (NB_LAZY_CONN/MDM) wins over everything; without a local override the
|
||||||
|
// management per-peer state applies (LazyStateLazy/Eager force the decision),
|
||||||
|
// and LazyStateDefault follows the account-wide flag.
|
||||||
|
func (e *ConnMgr) PeerLazyDefault(state mgmProto.LazyState) bool {
|
||||||
|
switch e.force {
|
||||||
|
case lazyForceOn:
|
||||||
|
return true
|
||||||
|
case lazyForceOff:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
switch state {
|
||||||
// if the lazy connection manager is already started, do not start it again
|
case mgmProto.LazyState_LazyStateLazy:
|
||||||
if e.lazyConnMgr != nil {
|
return true
|
||||||
return nil
|
case mgmProto.LazyState_LazyStateEager:
|
||||||
}
|
return false
|
||||||
|
default:
|
||||||
if e.rosenpassEnabled {
|
return e.remoteLazyEnabled
|
||||||
log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started")
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("lazy connection manager is enabled by the management feature flag")
|
|
||||||
e.initLazyManager(ctx)
|
|
||||||
e.statusRecorder.UpdateLazyConnection(true)
|
|
||||||
return e.addPeersToLazyConnManager()
|
|
||||||
} else {
|
|
||||||
if e.lazyConnMgr == nil {
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Infof("lazy connection manager is disabled by management feature flag")
|
|
||||||
e.closeManager(ctx)
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +146,13 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The exclude set is recomputed every sync but rarely changes; skip the O(n)
|
||||||
|
// store lookups and reconciliation when it matches what was already applied.
|
||||||
|
if maps.Equal(peerIDs, e.appliedExcludeList) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.appliedExcludeList = maps.Clone(peerIDs)
|
||||||
|
|
||||||
excludedPeers := make([]lazyconn.PeerConfig, 0, len(peerIDs))
|
excludedPeers := make([]lazyconn.PeerConfig, 0, len(peerIDs))
|
||||||
|
|
||||||
for peerID := range peerIDs {
|
for peerID := range peerIDs {
|
||||||
@@ -192,12 +188,16 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn) (exists bool) {
|
// AddPeerConn registers a peer connection. permanent requests an always-active connection
|
||||||
|
// (the peer belongs to the exclude set: a forwarder, or a peer that is not lazy by policy).
|
||||||
|
// Non-permanent peers are handed to the lazy manager. The subsequent SetExcludeList call
|
||||||
|
// reconciles membership for existing peers across flag flips.
|
||||||
|
func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, permanent bool) (exists bool) {
|
||||||
if success := e.peerStore.AddPeerConn(peerKey, conn); !success {
|
if success := e.peerStore.AddPeerConn(peerKey, conn); !success {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !e.isStartedWithLazyMgr() {
|
if !e.isStartedWithLazyMgr() || permanent {
|
||||||
if err := conn.Open(ctx); err != nil {
|
if err := conn.Open(ctx); err != nil {
|
||||||
conn.Log.Errorf("failed to open connection: %v", err)
|
conn.Log.Errorf("failed to open connection: %v", err)
|
||||||
}
|
}
|
||||||
@@ -296,6 +296,8 @@ func (e *ConnMgr) Close() {
|
|||||||
e.lazyConnMgrMu.Lock()
|
e.lazyConnMgrMu.Lock()
|
||||||
e.lazyConnMgr = nil
|
e.lazyConnMgr = nil
|
||||||
e.lazyConnMgrMu.Unlock()
|
e.lazyConnMgrMu.Unlock()
|
||||||
|
|
||||||
|
e.appliedExcludeList = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
||||||
@@ -309,6 +311,8 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
|||||||
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
|
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
|
||||||
e.lazyConnMgrMu.Unlock()
|
e.lazyConnMgrMu.Unlock()
|
||||||
|
|
||||||
|
e.appliedExcludeList = nil
|
||||||
|
|
||||||
e.wg.Add(1)
|
e.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer e.wg.Done()
|
defer e.wg.Done()
|
||||||
@@ -316,46 +320,6 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ConnMgr) addPeersToLazyConnManager() error {
|
|
||||||
peers := e.peerStore.PeersPubKey()
|
|
||||||
lazyPeerCfgs := make([]lazyconn.PeerConfig, 0, len(peers))
|
|
||||||
for _, peerID := range peers {
|
|
||||||
var peerConn *peer.Conn
|
|
||||||
var exists bool
|
|
||||||
if peerConn, exists = e.peerStore.PeerConn(peerID); !exists {
|
|
||||||
log.Warnf("failed to find peer conn for peerID: %s", peerID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lazyPeerCfg := lazyconn.PeerConfig{
|
|
||||||
PublicKey: peerID,
|
|
||||||
AllowedIPs: peerConn.WgConfig().AllowedIps,
|
|
||||||
PeerConnID: peerConn.ConnID(),
|
|
||||||
Log: peerConn.Log,
|
|
||||||
}
|
|
||||||
lazyPeerCfgs = append(lazyPeerCfgs, lazyPeerCfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.lazyConnMgr.AddActivePeers(lazyPeerCfgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConnMgr) closeManager(ctx context.Context) {
|
|
||||||
if e.lazyConnMgr == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConnMgr) isStartedWithLazyMgr() bool {
|
func (e *ConnMgr) isStartedWithLazyMgr() bool {
|
||||||
return e.lazyConnMgr != nil && e.lazyCtxCancel != nil
|
return e.lazyConnMgr != nil && e.lazyCtxCancel != nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||||
"github.com/netbirdio/netbird/monotime"
|
"github.com/netbirdio/netbird/monotime"
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveLazyForce(t *testing.T) {
|
func TestResolveLazyForce(t *testing.T) {
|
||||||
@@ -138,4 +139,91 @@ func TestInactivityThresholdEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPeerLazyDefault(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
force lazyForce
|
||||||
|
remoteEnabled bool
|
||||||
|
state mgmProto.LazyState
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "force on wins over eager state", force: lazyForceOn, state: mgmProto.LazyState_LazyStateEager, want: true},
|
||||||
|
{name: "force off wins over lazy state", force: lazyForceOff, remoteEnabled: true, state: mgmProto.LazyState_LazyStateLazy, want: false},
|
||||||
|
{name: "none, default, account off -> active", force: lazyForceNone, state: mgmProto.LazyState_LazyStateDefault, want: false},
|
||||||
|
{name: "none, default, account on -> lazy", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateDefault, want: true},
|
||||||
|
{name: "none, lazy state, account off -> lazy", force: lazyForceNone, state: mgmProto.LazyState_LazyStateLazy, want: true},
|
||||||
|
{name: "none, eager state, account on -> active", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateEager, want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled}
|
||||||
|
if got := e.PeerLazyDefault(tt.state); got != tt.want {
|
||||||
|
t.Fatalf("PeerLazyDefault(%v) = %v, want %v", tt.state, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func durPtr(d time.Duration) *time.Duration { return &d }
|
func durPtr(d time.Duration) *time.Duration { return &d }
|
||||||
|
|
||||||
|
// TestToExcludedLazyPeers covers the per-peer lazy classification (proxy vs
|
||||||
|
// normal, across the force/account-flag matrix). Forwarder-target exclusion is
|
||||||
|
// covered by TestToExcludedLazyPeers_ForwardTarget.
|
||||||
|
func TestToExcludedLazyPeers(t *testing.T) {
|
||||||
|
const (
|
||||||
|
normalKey = "normal"
|
||||||
|
lazyKey = "lazy-state"
|
||||||
|
eagerKey = "eager-state"
|
||||||
|
)
|
||||||
|
|
||||||
|
peers := []*mgmProto.RemotePeerConfig{
|
||||||
|
{WgPubKey: normalKey, AllowedIps: []string{"100.64.0.1/32"}},
|
||||||
|
{WgPubKey: lazyKey, AllowedIps: []string{"100.64.0.2/32"}, LazyState: mgmProto.LazyState_LazyStateLazy},
|
||||||
|
{WgPubKey: eagerKey, AllowedIps: []string{"100.64.0.3/32"}, LazyState: mgmProto.LazyState_LazyStateEager},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
force lazyForce
|
||||||
|
remoteEnabled bool
|
||||||
|
want map[string]bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "account off: lazy-state peer lazy, normal + eager active",
|
||||||
|
force: lazyForceNone, remoteEnabled: false,
|
||||||
|
want: map[string]bool{normalKey: true, eagerKey: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "account on: only eager-state peer active",
|
||||||
|
force: lazyForceNone, remoteEnabled: true,
|
||||||
|
want: map[string]bool{eagerKey: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "force off: everything active",
|
||||||
|
force: lazyForceOff, remoteEnabled: true,
|
||||||
|
want: map[string]bool{normalKey: true, lazyKey: true, eagerKey: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "force on: nothing active",
|
||||||
|
force: lazyForceOn, remoteEnabled: false,
|
||||||
|
want: map[string]bool{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := &Engine{connMgr: &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled}}
|
||||||
|
got := e.toExcludedLazyPeers(peers)
|
||||||
|
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("toExcludedLazyPeers() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
for k := range tt.want {
|
||||||
|
if !got[k] {
|
||||||
|
t.Fatalf("expected peer %s excluded, got %v", k, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/updater"
|
"github.com/netbirdio/netbird/client/internal/updater"
|
||||||
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||||
nbnet "github.com/netbirdio/netbird/client/net"
|
nbnet "github.com/netbirdio/netbird/client/net"
|
||||||
|
"github.com/netbirdio/netbird/client/netevents"
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
"github.com/netbirdio/netbird/client/ssh"
|
"github.com/netbirdio/netbird/client/ssh"
|
||||||
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
||||||
@@ -70,18 +71,31 @@ type ConnectClient struct {
|
|||||||
updateManager *updater.Manager
|
updateManager *updater.Manager
|
||||||
|
|
||||||
persistSyncResponse bool
|
persistSyncResponse bool
|
||||||
|
|
||||||
|
// netMgr gates every reconnection loop on OS-reported network
|
||||||
|
// availability and sweeps connections on network change.
|
||||||
|
netMgr *netevents.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectClientOption configures optional ConnectClient behavior.
|
||||||
|
type ConnectClientOption func(*ConnectClient)
|
||||||
|
|
||||||
|
// WithNetEvents injects the OS network event handling.
|
||||||
|
func WithNetEvents(events *netevents.Manager) ConnectClientOption {
|
||||||
|
return func(c *ConnectClient) { c.netMgr = events }
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConnectClient(
|
func NewConnectClient(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
config *profilemanager.Config,
|
config *profilemanager.Config,
|
||||||
statusRecorder *peer.Status,
|
statusRecorder *peer.Status,
|
||||||
|
opts ...ConnectClientOption,
|
||||||
) *ConnectClient {
|
) *ConnectClient {
|
||||||
// Derive the run context here so Stop owns the cancel that unblocks the run
|
// Derive the run context here so Stop owns the cancel that unblocks the run
|
||||||
// loop. runCancel is set once at construction, so Stop can call it without
|
// loop. runCancel is set once at construction, so Stop can call it without
|
||||||
// racing the run loop's startup. Callers therefore need not cancel before Stop.
|
// racing the run loop's startup. Callers therefore need not cancel before Stop.
|
||||||
runCtx, runCancel := context.WithCancel(ctx)
|
runCtx, runCancel := context.WithCancel(ctx)
|
||||||
return &ConnectClient{
|
c := &ConnectClient{
|
||||||
ctx: runCtx,
|
ctx: runCtx,
|
||||||
runCancel: runCancel,
|
runCancel: runCancel,
|
||||||
runExited: make(chan struct{}),
|
runExited: make(chan struct{}),
|
||||||
@@ -89,6 +103,10 @@ func NewConnectClient(
|
|||||||
statusRecorder: statusRecorder,
|
statusRecorder: statusRecorder,
|
||||||
engineMutex: sync.Mutex{},
|
engineMutex: sync.Mutex{},
|
||||||
}
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(c)
|
||||||
|
}
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
func (c *ConnectClient) SetUpdateManager(um *updater.Manager) {
|
||||||
@@ -224,7 +242,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
wrapErr := state.Wrap
|
wrapErr := state.Wrap
|
||||||
myPrivateKey, err := wgtypes.ParseKey(c.config.PrivateKey)
|
myPrivateKey, err := wgtypes.ParseKey(c.config.PrivateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed parsing Wireguard key %s: [%s]", c.config.PrivateKey, err.Error())
|
log.Errorf("failed parsing Wireguard key: %s", err)
|
||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +292,13 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// suspend connection attempts while the OS reports no usable network
|
||||||
|
if waited, err := c.netMgr.Wait(c.ctx); err != nil {
|
||||||
|
return nil
|
||||||
|
} else if waited {
|
||||||
|
backOff.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
state.Set(StatusConnecting)
|
state.Set(StatusConnecting)
|
||||||
|
|
||||||
engineCtx, cancel := context.WithCancel(c.ctx)
|
engineCtx, cancel := context.WithCancel(c.ctx)
|
||||||
@@ -285,7 +310,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
||||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled)
|
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled,
|
||||||
|
mgm.WithNetEvents(c.netMgr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// On daemon shutdown / Down() the parent context is cancelled
|
// On daemon shutdown / Down() the parent context is cancelled
|
||||||
// and the dial fails with "context canceled". Wrapping that
|
// and the dial fails with "context canceled". Wrapping that
|
||||||
@@ -360,7 +386,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// with the global Netbird config in hand connect (just a connection, no stream yet) Signal
|
// with the global Netbird config in hand connect (just a connection, no stream yet) Signal
|
||||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey)
|
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netMgr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
@@ -396,7 +422,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
engineConfig.StateDir = filepath.Dir(path)
|
engineConfig.StateDir = filepath.Dir(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
|
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU,
|
||||||
|
relayClient.WithNetEvents(c.netMgr))
|
||||||
c.statusRecorder.SetRelayMgr(relayManager)
|
c.statusRecorder.SetRelayMgr(relayManager)
|
||||||
if len(relayURLs) > 0 {
|
if len(relayURLs) > 0 {
|
||||||
if token != nil {
|
if token != nil {
|
||||||
@@ -424,6 +451,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
UpdateManager: c.updateManager,
|
UpdateManager: c.updateManager,
|
||||||
ClientMetrics: c.clientMetrics,
|
ClientMetrics: c.clientMetrics,
|
||||||
MetricsCtx: c.ctx,
|
MetricsCtx: c.ctx,
|
||||||
|
NetMgr: c.netMgr,
|
||||||
}, mobileDependency)
|
}, mobileDependency)
|
||||||
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
||||||
c.engine = engine
|
c.engine = engine
|
||||||
@@ -480,6 +508,16 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
|||||||
// status stream stuck at Connecting.
|
// status stream stuck at Connecting.
|
||||||
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
|
err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Once the client context is cancelled backoff.WithContext surfaces the
|
||||||
|
// bare context error, and any attempt torn down mid-flight reports the
|
||||||
|
// same. That cancellation is the caller asking us to stop (Stop, Down or
|
||||||
|
// an engine restart), so exit cleanly instead of handing back a failure
|
||||||
|
// the caller would have to distinguish from a real one.
|
||||||
|
if c.ctx.Err() != nil && errors.Is(err, context.Canceled) {
|
||||||
|
log.Info("exiting client retry loop, context cancelled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
|
||||||
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
|
||||||
state.Set(StatusNeedsLogin)
|
state.Set(StatusNeedsLogin)
|
||||||
@@ -614,6 +652,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
|||||||
RosenpassEnabled: config.RosenpassEnabled,
|
RosenpassEnabled: config.RosenpassEnabled,
|
||||||
RosenpassPermissive: config.RosenpassPermissive,
|
RosenpassPermissive: config.RosenpassPermissive,
|
||||||
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
||||||
|
RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(config.RemoteJobsAllowed),
|
||||||
EnableSSHRoot: config.EnableSSHRoot,
|
EnableSSHRoot: config.EnableSSHRoot,
|
||||||
EnableSSHSFTP: config.EnableSSHSFTP,
|
EnableSSHSFTP: config.EnableSSHSFTP,
|
||||||
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
||||||
@@ -673,7 +712,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// connectToSignal creates Signal Service client and established a connection
|
// connectToSignal creates Signal Service client and established a connection
|
||||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) {
|
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netMgr *netevents.Manager) (*signal.GrpcClient, error) {
|
||||||
var sigTLSEnabled bool
|
var sigTLSEnabled bool
|
||||||
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
|
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
|
||||||
sigTLSEnabled = true
|
sigTLSEnabled = true
|
||||||
@@ -681,7 +720,8 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
|
|||||||
sigTLSEnabled = false
|
sigTLSEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled)
|
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled,
|
||||||
|
signal.WithNetEvents(netMgr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err)
|
log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err)
|
||||||
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err)
|
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err)
|
||||||
@@ -710,6 +750,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
|||||||
config.EnableSSHLocalPortForwarding,
|
config.EnableSSHLocalPortForwarding,
|
||||||
config.EnableSSHRemotePortForwarding,
|
config.EnableSSHRemotePortForwarding,
|
||||||
config.DisableSSHAuth,
|
config.DisableSSHAuth,
|
||||||
|
config.RemoteJobsAllowed,
|
||||||
)
|
)
|
||||||
return client.Login(sysInfo, pubSSHKey, config.DNSLabels)
|
return client.Login(sysInfo, pubSSHKey, config.DNSLabels)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package daemonaddr
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// CarriesIdentity reports whether the control channel at addr conveys the
|
||||||
|
// connecting process's identity to the daemon. A Unix socket carries peer
|
||||||
|
// credentials and a named pipe carries the client's token. Nothing else does, TCP
|
||||||
|
// included, and there the daemon can authorize a privileged operation for nobody
|
||||||
|
// at all: see ResolveDaemonAddr, which says as much to anyone still reaching the
|
||||||
|
// Windows daemon on the address it served before it had a pipe.
|
||||||
|
//
|
||||||
|
// A client uses this to tell whether becoming privileged would get it anywhere.
|
||||||
|
// It answers from the scheme and nothing else, so an address it does not
|
||||||
|
// recognise counts as carrying no identity.
|
||||||
|
func CarriesIdentity(addr string) bool {
|
||||||
|
return strings.HasPrefix(addr, "unix://") || strings.HasPrefix(addr, pipeScheme)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package daemonaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCarriesIdentity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
addr string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"unix:///var/run/netbird.sock", true},
|
||||||
|
{"unix:///var/run/netbird/default.sock", true},
|
||||||
|
{"npipe://netbird", true},
|
||||||
|
{`npipe://\\.\pipe\ProtectedPrefix\Administrators\netbird`, true},
|
||||||
|
{"tcp://127.0.0.1:41731", false},
|
||||||
|
{"tcp://localhost:41731", false},
|
||||||
|
{"", false},
|
||||||
|
{"/var/run/netbird.sock", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.addr, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, CarriesIdentity(tt.addr), "address %q", tt.addr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -711,6 +711,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
|||||||
if g.internalConfig.ServerSSHAllowed != nil {
|
if g.internalConfig.ServerSSHAllowed != nil {
|
||||||
configContent.WriteString(fmt.Sprintf("ServerSSHAllowed: %v\n", *g.internalConfig.ServerSSHAllowed))
|
configContent.WriteString(fmt.Sprintf("ServerSSHAllowed: %v\n", *g.internalConfig.ServerSSHAllowed))
|
||||||
}
|
}
|
||||||
|
if g.internalConfig.RemoteJobsAllowed != nil {
|
||||||
|
configContent.WriteString(fmt.Sprintf("RemoteJobsAllowed: %v\n", *g.internalConfig.RemoteJobsAllowed))
|
||||||
|
}
|
||||||
if g.internalConfig.EnableSSHRoot != nil {
|
if g.internalConfig.EnableSSHRoot != nil {
|
||||||
configContent.WriteString(fmt.Sprintf("EnableSSHRoot: %v\n", *g.internalConfig.EnableSSHRoot))
|
configContent.WriteString(fmt.Sprintf("EnableSSHRoot: %v\n", *g.internalConfig.EnableSSHRoot))
|
||||||
}
|
}
|
||||||
@@ -737,6 +740,8 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
|||||||
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
|
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
|
||||||
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
|
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
|
||||||
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
|
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
|
||||||
|
configContent.WriteString(fmt.Sprintf("LocalMetricsEnabled: %v\n", g.internalConfig.LocalMetricsEnabled))
|
||||||
|
configContent.WriteString(fmt.Sprintf("LocalMetricsAddress: %s\n", g.internalConfig.LocalMetricsAddress))
|
||||||
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
|
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
|
||||||
|
|
||||||
if g.internalConfig.DisableNotifications != nil {
|
if g.internalConfig.DisableNotifications != nil {
|
||||||
|
|||||||
@@ -839,12 +839,13 @@ COMMIT`
|
|||||||
// the excluded set with a justification.
|
// the excluded set with a justification.
|
||||||
func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||||
excluded := map[string]string{
|
excluded := map[string]string{
|
||||||
"PrivateKey": "sensitive: WireGuard private key",
|
"PrivateKey": "sensitive: WireGuard private key",
|
||||||
"PreSharedKey": "sensitive: WireGuard pre-shared key",
|
"PreSharedKey": "sensitive: WireGuard pre-shared key",
|
||||||
"SSHKey": "sensitive: SSH private key",
|
"SSHKey": "sensitive: SSH private key",
|
||||||
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
|
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
|
||||||
"Name": "non-config: profile name is not needed for debug purposes",
|
"Name": "non-config: profile name is not needed for debug purposes",
|
||||||
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
|
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
|
||||||
|
"DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle",
|
||||||
}
|
}
|
||||||
|
|
||||||
mURL, _ := url.Parse("https://api.example.com:443")
|
mURL, _ := url.Parse("https://api.example.com:443")
|
||||||
@@ -864,6 +865,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
|||||||
RosenpassEnabled: true,
|
RosenpassEnabled: true,
|
||||||
RosenpassPermissive: true,
|
RosenpassPermissive: true,
|
||||||
ServerSSHAllowed: &bTrue,
|
ServerSSHAllowed: &bTrue,
|
||||||
|
RemoteJobsAllowed: &bTrue,
|
||||||
EnableSSHRoot: &bTrue,
|
EnableSSHRoot: &bTrue,
|
||||||
EnableSSHSFTP: &bTrue,
|
EnableSSHSFTP: &bTrue,
|
||||||
EnableSSHLocalPortForwarding: &bTrue,
|
EnableSSHLocalPortForwarding: &bTrue,
|
||||||
@@ -886,6 +888,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
|||||||
ClientCertPath: "/tmp/cert",
|
ClientCertPath: "/tmp/cert",
|
||||||
ClientCertKeyPath: "/tmp/key",
|
ClientCertKeyPath: "/tmp/key",
|
||||||
LazyConnection: "on",
|
LazyConnection: "on",
|
||||||
|
DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret",
|
||||||
MTU: 1280,
|
MTU: 1280,
|
||||||
DisableIPv6: true,
|
DisableIPv6: true,
|
||||||
SyncMessageVersion: func(v int) *int { return &v }(1),
|
SyncMessageVersion: func(v int) *int { return &v }(1),
|
||||||
@@ -903,6 +906,13 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
|||||||
g.addCommonConfigFields(&sb)
|
g.addCommonConfigFields(&sb)
|
||||||
rendered := sb.String() + renderAddConfigSpecific(g)
|
rendered := sb.String() + renderAddConfigSpecific(g)
|
||||||
|
|
||||||
|
// DebugBundleUploadURL is an MDM-provided value that can carry
|
||||||
|
// credentials or signed query tokens. It is deliberately excluded
|
||||||
|
// above; assert it never reaches the rendered bundle — neither the
|
||||||
|
// field name nor the token — in either anonymize mode.
|
||||||
|
assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle")
|
||||||
|
assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle")
|
||||||
|
|
||||||
val := reflect.ValueOf(cfg).Elem()
|
val := reflect.ValueOf(cfg).Elem()
|
||||||
typ := val.Type()
|
typ := val.Type()
|
||||||
var missing []string
|
var missing []string
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -34,8 +36,16 @@ var (
|
|||||||
// Registry locations of the host DNS configuration this package programs,
|
// Registry locations of the host DNS configuration this package programs,
|
||||||
// exported so a diagnostic reader reports the same locations that are written.
|
// exported so a diagnostic reader reports the same locations that are written.
|
||||||
const (
|
const (
|
||||||
// NRPTKeyPrefix starts the name of every NRPT rule key this client creates.
|
// NRPTKeyPrefix starts the name of every NRPT rule key this client creates:
|
||||||
NRPTKeyPrefix = "NetBird-Match"
|
// the match rules, the catch-all, and the .local exemption. Cleanup
|
||||||
|
// enumerates by this prefix, so a new kind of rule is removed by existing
|
||||||
|
// code as long as its key starts here.
|
||||||
|
NRPTKeyPrefix = "NetBird-"
|
||||||
|
|
||||||
|
// nrptMatchKeyName names the match-domain rules. Older versions used
|
||||||
|
// different layouts under the same name: a single unsuffixed key, then one
|
||||||
|
// key per domain, now one key per batch of domains.
|
||||||
|
nrptMatchKeyName = NRPTKeyPrefix + "Match"
|
||||||
|
|
||||||
// DNSPolicyConfigRoot holds the NRPT rules of the local policy store.
|
// DNSPolicyConfigRoot holds the NRPT rules of the local policy store.
|
||||||
DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
|
DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
|
||||||
@@ -51,8 +61,24 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix
|
dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + nrptMatchKeyName
|
||||||
gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix
|
gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + nrptMatchKeyName
|
||||||
|
|
||||||
|
dnsPolicyConfigExemptLocalPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal`
|
||||||
|
gpoDnsPolicyConfigExemptLocalPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix + `ExemptLocal`
|
||||||
|
|
||||||
|
nrptCatchAllNamespace = "."
|
||||||
|
// nrptLocalNamespace is reserved for multicast DNS by RFC 6762: a unicast
|
||||||
|
// resolver must not answer for it. The catch-all rule would hand it to us
|
||||||
|
// anyway, so it gets an exemption rule of its own.
|
||||||
|
nrptLocalNamespace = ".local"
|
||||||
|
|
||||||
|
// envLegacyDNSResolution restores the pre-catch-all behaviour: the adapter's
|
||||||
|
// NameServer alone, leaving the OS free to query other adapters' resolvers in
|
||||||
|
// parallel. An escape hatch for setups that depend on a resolver of theirs
|
||||||
|
// still being reachable while connected, at the cost of the leak and of the
|
||||||
|
// race the catch-all rule exists to close.
|
||||||
|
envLegacyDNSResolution = "NB_USE_LEGACY_DNS_RESOLUTION"
|
||||||
|
|
||||||
dnsPolicyConfigVersionKey = "Version"
|
dnsPolicyConfigVersionKey = "Version"
|
||||||
dnsPolicyConfigVersionValue = 2
|
dnsPolicyConfigVersionValue = 2
|
||||||
@@ -89,7 +115,6 @@ type registryConfigurator struct {
|
|||||||
guid string
|
guid string
|
||||||
routingAll bool
|
routingAll bool
|
||||||
gpo bool
|
gpo bool
|
||||||
nrptEntryCount int
|
|
||||||
origNameservers []netip.Addr
|
origNameservers []netip.Addr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +317,13 @@ func (r *registryConfigurator) disableWINSForInterface() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error {
|
func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error {
|
||||||
|
// Clear every rule the previous apply installed before installing any new
|
||||||
|
// one, including a leftover catch-all: removal is unconditional so a rule
|
||||||
|
// from an earlier run cannot survive into a config that no longer wants it.
|
||||||
|
if err := r.removeDNSMatchPolicies(); err != nil {
|
||||||
|
log.Errorf("cleanup old dns match policies: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
if config.RouteAll {
|
if config.RouteAll {
|
||||||
if err := r.addDNSSetupForAll(config.ServerIP); err != nil {
|
if err := r.addDNSSetupForAll(config.ServerIP); err != nil {
|
||||||
return fmt.Errorf("add dns setup: %w", err)
|
return fmt.Errorf("add dns setup: %w", err)
|
||||||
@@ -317,19 +349,28 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
|
|||||||
matchDomains = append(matchDomains, "."+strings.TrimSuffix(dConf.Domain, "."))
|
matchDomains = append(matchDomains, "."+strings.TrimSuffix(dConf.Domain, "."))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.removeDNSMatchPolicies(); err != nil {
|
// The root namespace is a match domain like any other: it just happens to
|
||||||
log.Errorf("cleanup old dns match policies: %s", err)
|
// match every name. Without it the adapter's NameServer only adds one more
|
||||||
|
// resolver to the set Windows queries in parallel, keeping whichever answer
|
||||||
|
// comes back first — which leaks every query to the local network and lets a
|
||||||
|
// resolver other than ours answer for a name we are authoritative for.
|
||||||
|
if config.RouteAll {
|
||||||
|
if parseBoolEnv(envLegacyDNSResolution) {
|
||||||
|
log.Infof("%s is set, leaving DNS resolution shared with the other adapters' resolvers instead of forcing it through %s", envLegacyDNSResolution, config.ServerIP)
|
||||||
|
} else {
|
||||||
|
matchDomains = append(matchDomains, nrptCatchAllNamespace)
|
||||||
|
log.Infof("routing every namespace through %s: DNS resolution is now exclusive to NetBird", config.ServerIP)
|
||||||
|
|
||||||
|
if err := r.addDNSExemptLocalPolicy(); err != nil {
|
||||||
|
return fmt.Errorf("add dns exempt policy: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matchDomains) != 0 {
|
if len(matchDomains) != 0 {
|
||||||
count, err := r.addDNSMatchPolicy(matchDomains, config.ServerIP)
|
if err := r.addDNSMatchPolicy(matchDomains, config.ServerIP); err != nil {
|
||||||
// Update count even on error to ensure cleanup covers partially created rules
|
|
||||||
r.nrptEntryCount = count
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("add dns match policy: %w", err)
|
return fmt.Errorf("add dns match policy: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
r.nrptEntryCount = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
r.updateState(stateManager)
|
r.updateState(stateManager)
|
||||||
@@ -345,9 +386,8 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
|
|||||||
|
|
||||||
func (r *registryConfigurator) updateState(stateManager *statemanager.Manager) {
|
func (r *registryConfigurator) updateState(stateManager *statemanager.Manager) {
|
||||||
if err := stateManager.UpdateState(&ShutdownState{
|
if err := stateManager.UpdateState(&ShutdownState{
|
||||||
Guid: r.guid,
|
Guid: r.guid,
|
||||||
GPO: r.gpo,
|
GPO: r.gpo,
|
||||||
NRPTEntryCount: r.nrptEntryCount,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Errorf("failed to update shutdown state: %s", err)
|
log.Errorf("failed to update shutdown state: %s", err)
|
||||||
}
|
}
|
||||||
@@ -362,7 +402,7 @@ func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) (int, error) {
|
func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) error {
|
||||||
// if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored
|
// if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored
|
||||||
// see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745
|
// see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745
|
||||||
|
|
||||||
@@ -379,19 +419,17 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
|
|||||||
gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, ruleIndex)
|
gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, ruleIndex)
|
||||||
|
|
||||||
if err := r.configureDNSPolicy(localPath, batchDomains, ip); err != nil {
|
if err := r.configureDNSPolicy(localPath, batchDomains, ip); err != nil {
|
||||||
return ruleIndex, fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err)
|
return fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment immediately so the caller's cleanup path knows about this rule
|
|
||||||
ruleIndex++
|
|
||||||
|
|
||||||
if r.gpo {
|
if r.gpo {
|
||||||
if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil {
|
if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil {
|
||||||
return ruleIndex, fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex-1, err)
|
return fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf("added NRPT rule %d with %d domains", ruleIndex-1, len(batchDomains))
|
log.Debugf("added NRPT rule %d with %d domains", ruleIndex, len(batchDomains))
|
||||||
|
ruleIndex++
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.gpo {
|
if r.gpo {
|
||||||
@@ -401,9 +439,45 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains))
|
log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains))
|
||||||
return ruleIndex, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addDNSExemptLocalPolicy carves .local back out of the catch-all. RFC 6762
|
||||||
|
// reserves it for multicast DNS, so forwarding those names to a unicast
|
||||||
|
// upstream answers NXDOMAIN for hosts that do exist - printers, NAS boxes, and
|
||||||
|
// anything else announcing itself on the link - and the answer is authoritative
|
||||||
|
// enough that Windows stops looking. A rule naming the namespace with no
|
||||||
|
// servers hands it back to the DNS client untouched. A more specific rule still
|
||||||
|
// wins, so a match domain under .local keeps going through us.
|
||||||
|
func (r *registryConfigurator) addDNSExemptLocalPolicy() error {
|
||||||
|
var noServers netip.Addr
|
||||||
|
|
||||||
|
if err := r.configureDNSPolicy(dnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil {
|
||||||
|
return fmt.Errorf("configure exempt policy for %s: %w", nrptLocalNamespace, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.gpo {
|
||||||
|
if err := r.configureDNSPolicy(gpoDnsPolicyConfigExemptLocalPath, []string{nrptLocalNamespace}, noServers); err != nil {
|
||||||
|
return fmt.Errorf("configure gpo exempt policy for %s: %w", nrptLocalNamespace, err)
|
||||||
|
}
|
||||||
|
if err := refreshGroupPolicy(); err != nil {
|
||||||
|
log.Warnf("failed to refresh group policy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("added NRPT exemption for %s, leaving it to the OS resolver", nrptLocalNamespace)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureDNSPolicy writes one NRPT rule. An invalid ip writes an exemption
|
||||||
|
// rule: the namespace with an empty server list, which tells the DNS client to
|
||||||
|
// resolve those names the way it would without any rule at all.
|
||||||
|
//
|
||||||
|
// The empty string is the whole difference, and it has to be written: dropping
|
||||||
|
// the value and clearing ConfigOptions instead produces a rule Windows treats
|
||||||
|
// as a no-op, keeps out of Get-DnsClientNrptPolicy -Effective, and ignores in
|
||||||
|
// favour of the catch-all. 0x8 says the server list is the meaningful part of
|
||||||
|
// the rule, and an empty list then means "no server, resolve normally".
|
||||||
func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error {
|
func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error {
|
||||||
if err := removeRegistryKeyFromDNSPolicyConfig(policyPath); err != nil {
|
if err := removeRegistryKeyFromDNSPolicyConfig(policyPath); err != nil {
|
||||||
return fmt.Errorf("remove existing dns policy: %w", err)
|
return fmt.Errorf("remove existing dns policy: %w", err)
|
||||||
@@ -423,7 +497,11 @@ func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []s
|
|||||||
return fmt.Errorf("set %s: %w", dnsPolicyConfigNameKey, err)
|
return fmt.Errorf("set %s: %w", dnsPolicyConfigNameKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, ip.String()); err != nil {
|
var servers string
|
||||||
|
if ip.IsValid() {
|
||||||
|
servers = ip.String()
|
||||||
|
}
|
||||||
|
if err := regKey.SetStringValue(dnsPolicyConfigGenericDNSServersKey, servers); err != nil {
|
||||||
return fmt.Errorf("set %s: %w", dnsPolicyConfigGenericDNSServersKey, err)
|
return fmt.Errorf("set %s: %w", dnsPolicyConfigGenericDNSServersKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +544,7 @@ func (r *registryConfigurator) flushDNSCache() {
|
|||||||
|
|
||||||
ret, _, err := dnsFlushResolverCacheFn.Call()
|
ret, _, err := dnsFlushResolverCacheFn.Call()
|
||||||
if ret == 0 {
|
if ret == 0 {
|
||||||
if err != nil && !errors.Is(err, syscall.Errno(0)) {
|
if !errors.Is(err, syscall.Errno(0)) {
|
||||||
log.Errorf("DnsFlushResolverCache failed: %v", err)
|
log.Errorf("DnsFlushResolverCache failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -521,8 +599,11 @@ func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *registryConfigurator) restoreHostDNS() error {
|
func (r *registryConfigurator) restoreHostDNS() error {
|
||||||
|
// Propagated, unlike in applyDNSConfig: there we are about to write fresh
|
||||||
|
// rules over whatever survived, here we are leaving, and a rule left behind
|
||||||
|
// keeps sending every query to an address that is about to disappear.
|
||||||
if err := r.removeDNSMatchPolicies(); err != nil {
|
if err := r.removeDNSMatchPolicies(); err != nil {
|
||||||
log.Errorf("remove dns match policies: %s", err)
|
return fmt.Errorf("remove dns match policies: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey); err != nil {
|
if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey); err != nil {
|
||||||
@@ -534,28 +615,28 @@ func (r *registryConfigurator) restoreHostDNS() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeDNSMatchPolicies deletes every NRPT rule this client may have created,
|
||||||
|
// from the local and the GPO policy store. The rules are found by enumerating
|
||||||
|
// the registry, the only authoritative record of what was written. Cleanup must
|
||||||
|
// not depend on a rule count: the in-memory one is scoped to a single
|
||||||
|
// registryConfigurator and the persisted one is deleted on every clean
|
||||||
|
// disconnect, and a rule left behind keeps resolving names over an interface
|
||||||
|
// that is gone, until reboot discards the volatile key.
|
||||||
func (r *registryConfigurator) removeDNSMatchPolicies() error {
|
func (r *registryConfigurator) removeDNSMatchPolicies() error {
|
||||||
var merr *multierror.Error
|
var merr *multierror.Error
|
||||||
|
|
||||||
// Try to remove the base entries (for backward compatibility)
|
for _, root := range []string{DNSPolicyConfigRoot, GPODNSPolicyConfigRoot} {
|
||||||
if err := removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath); err != nil {
|
names, err := listNRPTRuleKeys(root)
|
||||||
merr = multierror.Append(merr, fmt.Errorf("remove local base entry: %w", err))
|
if err != nil {
|
||||||
}
|
merr = multierror.Append(merr, fmt.Errorf("list rule keys under %s: %w", root, err))
|
||||||
|
continue
|
||||||
if err := removeRegistryKeyFromDNSPolicyConfig(gpoDnsPolicyConfigMatchPath); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("remove GPO base entry: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < r.nrptEntryCount; i++ {
|
|
||||||
localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)
|
|
||||||
gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, i)
|
|
||||||
|
|
||||||
if err := removeRegistryKeyFromDNSPolicyConfig(localPath); err != nil {
|
|
||||||
merr = multierror.Append(merr, fmt.Errorf("remove local entry %d: %w", i, err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := removeRegistryKeyFromDNSPolicyConfig(gpoPath); err != nil {
|
for _, name := range names {
|
||||||
merr = multierror.Append(merr, fmt.Errorf("remove GPO entry %d: %w", i, err))
|
path := root + `\` + name
|
||||||
|
if err := removeRegistryKeyFromDNSPolicyConfig(path); err != nil {
|
||||||
|
merr = multierror.Append(merr, fmt.Errorf("remove entry %s: %w", path, err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,11 +651,52 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error {
|
|||||||
return r.restoreHostDNS()
|
return r.restoreHostDNS()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store
|
||||||
|
// root. An absent root holds nothing to clean up, which is the normal state of
|
||||||
|
// the GPO store on a machine without DNS Client policy.
|
||||||
|
func listNRPTRuleKeys(root string) ([]string, error) {
|
||||||
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
|
||||||
|
// the GPO store is absent on a machine without DNS client policy
|
||||||
|
log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", root)
|
||||||
|
return nil, nil
|
||||||
|
case err != nil:
|
||||||
|
// any other failure has to reach the caller: reporting no rules would
|
||||||
|
// report a successful cleanup while leaving the rules in place
|
||||||
|
return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err)
|
||||||
|
}
|
||||||
|
defer closer(k)
|
||||||
|
|
||||||
|
names, err := k.ReadSubKeyNames(-1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read subkey names: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ruleKeys []string
|
||||||
|
for _, name := range names {
|
||||||
|
// registry key names are case insensitive
|
||||||
|
if strings.HasPrefix(strings.ToLower(name), strings.ToLower(NRPTKeyPrefix)) {
|
||||||
|
ruleKeys = append(ruleKeys, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ruleKeys, nil
|
||||||
|
}
|
||||||
|
|
||||||
func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error {
|
func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error {
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE)
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE)
|
||||||
if err != nil {
|
switch {
|
||||||
log.Debugf("failed to open HKEY_LOCAL_MACHINE\\%s: %v", regKeyPath, err)
|
case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND):
|
||||||
|
// nothing to remove, which is the normal case for a rule this config
|
||||||
|
// never installed
|
||||||
|
log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", regKeyPath)
|
||||||
return nil
|
return nil
|
||||||
|
case err != nil:
|
||||||
|
// anything else has to reach the caller: reporting success here would
|
||||||
|
// leave the rule in force while claiming it was removed, which is how a
|
||||||
|
// stale rule outlives the interface it points at
|
||||||
|
return fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
closer(k)
|
closer(k)
|
||||||
@@ -601,7 +723,7 @@ func refreshGroupPolicy() error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ret == 0 {
|
if ret == 0 {
|
||||||
if err != nil && !errors.Is(err, syscall.Errno(0)) {
|
if !errors.Is(err, syscall.Errno(0)) {
|
||||||
return fmt.Errorf("RefreshPolicyEx failed: %w", err)
|
return fmt.Errorf("RefreshPolicyEx failed: %w", err)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("RefreshPolicyEx failed")
|
return fmt.Errorf("RefreshPolicyEx failed")
|
||||||
@@ -610,6 +732,20 @@ func refreshGroupPolicy() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBoolEnv(key string) bool {
|
||||||
|
val := os.Getenv(key)
|
||||||
|
if val == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.ParseBool(val)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("failed to parse %s=%q: %v", key, val, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
func closer(closer io.Closer) {
|
func closer(closer io.Closer) {
|
||||||
if err := closer.Close(); err != nil {
|
if err := closer.Close(); err != nil {
|
||||||
log.Errorf("failed to close: %s", err)
|
log.Errorf("failed to close: %s", err)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
|||||||
|
|
||||||
// Create a test interface registry key so updateSearchDomains doesn't fail
|
// Create a test interface registry key so updateSearchDomains doesn't fail
|
||||||
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
||||||
interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID
|
interfacePath := InterfaceConfigPath + `\` + testGUID
|
||||||
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
||||||
require.NoError(t, err, "Should create test interface registry key")
|
require.NoError(t, err, "Should create test interface registry key")
|
||||||
testKey.Close()
|
testKey.Close()
|
||||||
@@ -56,7 +56,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify 3 NRPT rules exist
|
// Verify 3 NRPT rules exist
|
||||||
assert.Equal(t, 3, cfg.nrptEntryCount, "Should create 3 NRPT rules for 125 domains")
|
assert.Equal(t, 3, countNRPTRuleKeys(t), "Should create 3 NRPT rules for 125 domains")
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i))
|
exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -81,7 +81,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify first 2 NRPT rules exist
|
// Verify first 2 NRPT rules exist
|
||||||
assert.Equal(t, 2, cfg.nrptEntryCount, "Should create 2 NRPT rules for 75 domains")
|
assert.Equal(t, 2, countNRPTRuleKeys(t), "Should create 2 NRPT rules for 75 domains")
|
||||||
for i := 0; i < 2; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i))
|
exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -94,6 +94,145 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
|||||||
assert.False(t, exists, "NRPT rule 2 should NOT exist after reducing to 75 domains")
|
assert.False(t, exists, "NRPT rule 2 should NOT exist after reducing to 75 domains")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNRPTCatchAllRule verifies that RouteAll adds the root namespace to the
|
||||||
|
// match rule instead of a rule of its own, that .local is carved back out with
|
||||||
|
// an empty server list, and that both go away when RouteAll is cleared or the
|
||||||
|
// host DNS is restored.
|
||||||
|
func TestNRPTCatchAllRule(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping registry integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
defer cleanupRegistryKeys(t)
|
||||||
|
cleanupRegistryKeys(t)
|
||||||
|
|
||||||
|
testIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
||||||
|
interfacePath := InterfaceConfigPath + `\` + testGUID
|
||||||
|
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
||||||
|
require.NoError(t, err, "Should create test interface registry key")
|
||||||
|
require.NoError(t, testKey.Close(), "close test interface registry key")
|
||||||
|
defer func() {
|
||||||
|
assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key")
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := ®istryConfigurator{guid: testGUID}
|
||||||
|
|
||||||
|
matchOnly := HostDNSConfig{
|
||||||
|
ServerIP: testIP,
|
||||||
|
Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}},
|
||||||
|
}
|
||||||
|
primary := HostDNSConfig{
|
||||||
|
ServerIP: testIP,
|
||||||
|
RouteAll: true,
|
||||||
|
Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}},
|
||||||
|
}
|
||||||
|
firstRule := fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath)
|
||||||
|
|
||||||
|
// The root namespace is not a rule of its own: it rides in the match rule,
|
||||||
|
// which is the point of it not being a special case.
|
||||||
|
require.NoError(t, cfg.applyDNSConfig(matchOnly, nil))
|
||||||
|
names := ruleNamespaces(t, firstRule)
|
||||||
|
assert.Contains(t, names, ".example.com")
|
||||||
|
assert.NotContains(t, names, nrptCatchAllNamespace, "a match-only config must not claim every namespace")
|
||||||
|
|
||||||
|
require.NoError(t, cfg.applyDNSConfig(primary, nil))
|
||||||
|
names = ruleNamespaces(t, firstRule)
|
||||||
|
assert.Contains(t, names, ".example.com")
|
||||||
|
assert.Contains(t, names, nrptCatchAllNamespace, "RouteAll should add the root namespace to the match rule")
|
||||||
|
|
||||||
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, firstRule, registry.QUERY_VALUE)
|
||||||
|
require.NoError(t, err)
|
||||||
|
servers, _, err := k.GetStringValue(dnsPolicyConfigGenericDNSServersKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testIP.String(), servers, "every namespace in the rule resolves through our resolver")
|
||||||
|
require.NoError(t, k.Close(), "close match rule key")
|
||||||
|
|
||||||
|
// .local is carved back out: RFC 6762 reserves it for mDNS, so it needs a
|
||||||
|
// rule of its own — it is the one rule with a different server list.
|
||||||
|
ek, err := registry.OpenKey(registry.LOCAL_MACHINE, dnsPolicyConfigExemptLocalPath, registry.QUERY_VALUE)
|
||||||
|
require.NoError(t, err, "exemption rule should exist once the root namespace is claimed")
|
||||||
|
|
||||||
|
exemptNames, _, err := ek.GetStringsValue(dnsPolicyConfigNameKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{nrptLocalNamespace}, exemptNames, "the exemption should name only the mDNS namespace")
|
||||||
|
|
||||||
|
exemptServers, _, err := ek.GetStringValue(dnsPolicyConfigGenericDNSServersKey)
|
||||||
|
require.NoError(t, err, "the value has to be present, empty: without it Windows drops the rule")
|
||||||
|
assert.Empty(t, exemptServers, "an exemption rule lists no servers")
|
||||||
|
|
||||||
|
exemptOpts, _, err := ek.GetIntegerValue(dnsPolicyConfigConfigOptionsKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, dnsPolicyConfigConfigOptionsValue, exemptOpts, "same options as a normal rule; the empty server list is what makes it an exemption")
|
||||||
|
require.NoError(t, ek.Close(), "close exemption rule key")
|
||||||
|
|
||||||
|
require.NoError(t, cfg.applyDNSConfig(matchOnly, nil))
|
||||||
|
names = ruleNamespaces(t, firstRule)
|
||||||
|
assert.NotContains(t, names, nrptCatchAllNamespace, "clearing RouteAll should drop the root namespace")
|
||||||
|
|
||||||
|
exists, err := registryKeyExists(dnsPolicyConfigExemptLocalPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists, "exemption rule should go with the namespace it carves out of")
|
||||||
|
|
||||||
|
require.NoError(t, cfg.applyDNSConfig(primary, nil))
|
||||||
|
require.NoError(t, cfg.restoreHostDNS())
|
||||||
|
exists, err = registryKeyExists(firstRule)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists, "restore should leave no rule behind")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleNamespaces returns the namespaces an NRPT rule key claims.
|
||||||
|
func ruleNamespaces(t *testing.T, path string) []string {
|
||||||
|
t.Helper()
|
||||||
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
|
||||||
|
require.NoError(t, err, "rule key %s should exist", path)
|
||||||
|
defer k.Close()
|
||||||
|
|
||||||
|
names, _, err := k.GetStringsValue(dnsPolicyConfigNameKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNRPTCatchAllRuleLegacyEnv verifies that NB_USE_LEGACY_DNS_RESOLUTION
|
||||||
|
// leaves the root namespace unclaimed, so no rule is written for a RouteAll
|
||||||
|
// config that carries no match domains.
|
||||||
|
func TestNRPTCatchAllRuleLegacyEnv(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping registry integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
defer cleanupRegistryKeys(t)
|
||||||
|
cleanupRegistryKeys(t)
|
||||||
|
|
||||||
|
t.Setenv(envLegacyDNSResolution, "true")
|
||||||
|
|
||||||
|
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
||||||
|
interfacePath := InterfaceConfigPath + `\` + testGUID
|
||||||
|
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
||||||
|
require.NoError(t, err, "Should create test interface registry key")
|
||||||
|
require.NoError(t, testKey.Close(), "close test interface registry key")
|
||||||
|
defer func() {
|
||||||
|
assert.NoError(t, registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath), "delete test interface registry key")
|
||||||
|
}()
|
||||||
|
|
||||||
|
cfg := ®istryConfigurator{guid: testGUID}
|
||||||
|
config := HostDNSConfig{
|
||||||
|
ServerIP: netip.MustParseAddr("100.64.0.1"),
|
||||||
|
RouteAll: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, cfg.applyDNSConfig(config, nil))
|
||||||
|
|
||||||
|
// RouteAll with no match domains and the switch set leaves nothing to write.
|
||||||
|
exists, err := registryKeyExists(fmt.Sprintf("%s-0", dnsPolicyConfigMatchPath))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists, "no rule should be written when the legacy env var is set")
|
||||||
|
|
||||||
|
exists, err = registryKeyExists(dnsPolicyConfigExemptLocalPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists, "no exemption without a claimed root namespace")
|
||||||
|
}
|
||||||
|
|
||||||
func registryKeyExists(path string) (bool, error) {
|
func registryKeyExists(path string) (bool, error) {
|
||||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,9 +245,65 @@ func registryKeyExists(path string) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNRPTCleanupWithoutRuleCount verifies that rules written by a previous run
|
||||||
|
// are removed by a configurator that has no record of how many there are: an
|
||||||
|
// unclean exit loses the in-memory count and a clean disconnect deletes the
|
||||||
|
// persisted one, so cleanup cannot depend on either.
|
||||||
|
func TestNRPTCleanupWithoutRuleCount(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping registry integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
defer cleanupRegistryKeys(t)
|
||||||
|
cleanupRegistryKeys(t)
|
||||||
|
|
||||||
|
testIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
|
||||||
|
// 75 domains produce two indexed rules, as the current layout does
|
||||||
|
domains := make([]string, 75)
|
||||||
|
for i := range domains {
|
||||||
|
domains[i] = fmt.Sprintf(".domain%d.com", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousRun := ®istryConfigurator{}
|
||||||
|
require.NoError(t, previousRun.addDNSMatchPolicy(domains, testIP))
|
||||||
|
|
||||||
|
// the unsuffixed key an older version would have written
|
||||||
|
require.NoError(t, previousRun.configureDNSPolicy(dnsPolicyConfigMatchPath, []string{".legacy.example.com"}, testIP))
|
||||||
|
|
||||||
|
// a policy owned by someone else, which cleanup must not touch
|
||||||
|
foreignPath := DNSPolicyConfigRoot + `\DnsPolicyConfigTestForeign`
|
||||||
|
foreignKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, foreignPath, registry.SET_VALUE)
|
||||||
|
require.NoError(t, err, "Should create foreign policy key")
|
||||||
|
foreignKey.Close()
|
||||||
|
defer func() {
|
||||||
|
_ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignPath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.Equal(t, 3, countNRPTRuleKeys(t), "Should have two indexed rules and the legacy one")
|
||||||
|
|
||||||
|
// a configurator that never applied a DNS config, as one built after a
|
||||||
|
// restart or from a shutdown state without a count is
|
||||||
|
freshRun := ®istryConfigurator{}
|
||||||
|
require.NoError(t, freshRun.removeDNSMatchPolicies())
|
||||||
|
|
||||||
|
assert.Equal(t, 0, countNRPTRuleKeys(t), "Should remove every rule left by the previous run")
|
||||||
|
|
||||||
|
exists, err := registryKeyExists(foreignPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "Should not remove a policy that is not ours")
|
||||||
|
}
|
||||||
|
|
||||||
|
func countNRPTRuleKeys(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
names, err := listNRPTRuleKeys(DNSPolicyConfigRoot)
|
||||||
|
require.NoError(t, err, "Should list NRPT rule keys")
|
||||||
|
return len(names)
|
||||||
|
}
|
||||||
|
|
||||||
func cleanupRegistryKeys(*testing.T) {
|
func cleanupRegistryKeys(*testing.T) {
|
||||||
// Clean up more entries to account for batching tests with many domains
|
cfg := ®istryConfigurator{}
|
||||||
cfg := ®istryConfigurator{nrptEntryCount: 20}
|
|
||||||
_ = cfg.removeDNSMatchPolicies()
|
_ = cfg.removeDNSMatchPolicies()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +320,7 @@ func TestNRPTDomainBatching(t *testing.T) {
|
|||||||
|
|
||||||
// Create a test interface registry key so updateSearchDomains doesn't fail
|
// Create a test interface registry key so updateSearchDomains doesn't fail
|
||||||
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
testGUID := "{12345678-1234-1234-1234-123456789ABC}"
|
||||||
interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID
|
interfacePath := InterfaceConfigPath + `\` + testGUID
|
||||||
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE)
|
||||||
require.NoError(t, err, "Should create test interface registry key")
|
require.NoError(t, err, "Should create test interface registry key")
|
||||||
testKey.Close()
|
testKey.Close()
|
||||||
@@ -193,7 +388,7 @@ func TestNRPTDomainBatching(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify that exactly expectedRuleCount rules were created
|
// Verify that exactly expectedRuleCount rules were created
|
||||||
assert.Equal(t, tc.expectedRuleCount, cfg.nrptEntryCount,
|
assert.Equal(t, tc.expectedRuleCount, countNRPTRuleKeys(t),
|
||||||
"Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount)
|
"Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount)
|
||||||
|
|
||||||
// Verify all expected rules exist
|
// Verify all expected rules exist
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ func TestResolver_StaleTriggersAsyncRefresh(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) {
|
func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) {
|
||||||
|
semaphore := make(chan struct{})
|
||||||
r := NewResolver()
|
r := NewResolver()
|
||||||
chain := newFakeChain()
|
chain := newFakeChain()
|
||||||
chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2")
|
chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2")
|
||||||
@@ -239,7 +240,7 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(50 * time.Millisecond) // hold inflight long enough to collide
|
<-semaphore // block the call to force request collision
|
||||||
}
|
}
|
||||||
|
|
||||||
r.SetChainResolver(chain, 50)
|
r.SetChainResolver(chain, 50)
|
||||||
@@ -255,17 +256,17 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) {
|
|||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i := 0; i < 50; i++ {
|
for i := 0; i < 50; i++ {
|
||||||
wg.Add(1)
|
wg.Go(func() {
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
queryA(t, r, "mgmt.example.com.")
|
queryA(t, r, "mgmt.example.com.")
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.Eventually(t, func() bool { return inflight.Load() >= 1 }, 2*time.Second, 100*time.Millisecond)
|
||||||
|
|
||||||
|
close(semaphore)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
waitFor(t, 2*time.Second, func() bool {
|
assert.Eventually(t, func() bool { return inflight.Load() == 0 }, 2*time.Second, 100*time.Millisecond)
|
||||||
return inflight.Load() == 0
|
|
||||||
})
|
|
||||||
|
|
||||||
calls := chain.callCount("mgmt.example.com.", dns.TypeA)
|
calls := chain.callCount("mgmt.example.com.", dns.TypeA)
|
||||||
assert.LessOrEqual(t, calls, 2, "singleflight must collapse concurrent refreshes (got %d)", calls)
|
assert.LessOrEqual(t, calls, 2, "singleflight must collapse concurrent refreshes (got %d)", calls)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"go.uber.org/mock/gomock"
|
||||||
"github.com/google/gopacket"
|
"github.com/google/gopacket"
|
||||||
"github.com/google/gopacket/layers"
|
"github.com/google/gopacket/layers"
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"go.uber.org/mock/gomock"
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ShutdownState struct {
|
type ShutdownState struct {
|
||||||
Guid string
|
Guid string
|
||||||
GPO bool
|
GPO bool
|
||||||
NRPTEntryCount int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShutdownState) Name() string {
|
func (s *ShutdownState) Name() string {
|
||||||
@@ -16,9 +15,8 @@ func (s *ShutdownState) Name() string {
|
|||||||
|
|
||||||
func (s *ShutdownState) Cleanup() error {
|
func (s *ShutdownState) Cleanup() error {
|
||||||
manager := ®istryConfigurator{
|
manager := ®istryConfigurator{
|
||||||
guid: s.Guid,
|
guid: s.Guid,
|
||||||
gpo: s.GPO,
|
gpo: s.GPO,
|
||||||
nrptEntryCount: s.NRPTEntryCount,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := manager.restoreUncleanShutdownDNS(); err != nil {
|
if err := manager.restoreUncleanShutdownDNS(); err != nil {
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||||
nbdns "github.com/netbirdio/netbird/dns"
|
nbdns "github.com/netbirdio/netbird/dns"
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreatePTRRecord_IPv4(t *testing.T) {
|
func TestCreatePTRRecord_IPv4(t *testing.T) {
|
||||||
@@ -136,3 +138,88 @@ func TestAddReverseZone_IPv6(t *testing.T) {
|
|||||||
assert.Len(t, reverseZone.Records, 1)
|
assert.Len(t, reverseZone.Records, 1)
|
||||||
assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type)
|
assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestToDNSConfig_ZoneFlagsPreserved pins the per-zone NonAuthoritative flag
|
||||||
|
// through the legacy DNSConfig path. A non-authoritative zone is match-only:
|
||||||
|
// the local resolver falls through to the upstream for an in-zone name it does
|
||||||
|
// not define. The built-in peer zone is the authoritative one and must stay
|
||||||
|
// that way, so the flag has to travel per zone rather than be derived.
|
||||||
|
func TestToDNSConfig_ZoneFlagsPreserved(t *testing.T) {
|
||||||
|
config := toDNSConfig(&mgmProto.DNSConfig{
|
||||||
|
ServiceEnable: true,
|
||||||
|
CustomZones: []*mgmProto.CustomZone{
|
||||||
|
{
|
||||||
|
Domain: "netbird.cloud.",
|
||||||
|
Records: []*mgmProto.SimpleRecord{
|
||||||
|
{Name: "peer1.netbird.cloud.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Domain: "corp.internal.",
|
||||||
|
NonAuthoritative: true,
|
||||||
|
SearchDomainDisabled: true,
|
||||||
|
Records: []*mgmProto.SimpleRecord{
|
||||||
|
{Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, wgaddr.Address{
|
||||||
|
IP: netip.MustParseAddr("100.64.0.1"),
|
||||||
|
Network: netip.MustParsePrefix("100.64.0.0/16"),
|
||||||
|
})
|
||||||
|
|
||||||
|
zones := make(map[string]nbdns.CustomZone, len(config.CustomZones))
|
||||||
|
for _, zone := range config.CustomZones {
|
||||||
|
zones[zone.Domain] = zone
|
||||||
|
}
|
||||||
|
|
||||||
|
peerZone, ok := zones["netbird.cloud."]
|
||||||
|
require.True(t, ok, "peer zone must survive")
|
||||||
|
assert.False(t, peerZone.NonAuthoritative, "the built-in peer zone owns the account domain and stays authoritative")
|
||||||
|
|
||||||
|
accountZone, ok := zones["corp.internal."]
|
||||||
|
require.True(t, ok, "account zone must survive")
|
||||||
|
assert.True(t, accountZone.NonAuthoritative, "an account zone stays match-only, else undefined in-zone names get black-holed")
|
||||||
|
assert.True(t, accountZone.SearchDomainDisabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestToDNSConfig_SingleZoneForcedAuthoritative pins the compatibility clause
|
||||||
|
// in toDNSConfig: a config carrying exactly one zone is treated as
|
||||||
|
// authoritative no matter what the server said, because servers that predate
|
||||||
|
// the NonAuthoritative field send only the peer FQDN zone.
|
||||||
|
//
|
||||||
|
// The clause can only ever downgrade an explicit true to false, so a server
|
||||||
|
// that legitimately sends a single non-authoritative zone — an account whose
|
||||||
|
// only zone is a custom one, with no peer records to build the built-in zone
|
||||||
|
// from — gets that zone's whole apex black-holed on the client. Real accounts
|
||||||
|
// always carry the peer zone alongside, which is why this is latent. Narrowing
|
||||||
|
// it needs a way to tell "unset" from "false" on the wire, or the account
|
||||||
|
// domain passed down here; until then this test states the contract so a
|
||||||
|
// change to it is deliberate.
|
||||||
|
func TestToDNSConfig_SingleZoneForcedAuthoritative(t *testing.T) {
|
||||||
|
config := toDNSConfig(&mgmProto.DNSConfig{
|
||||||
|
ServiceEnable: true,
|
||||||
|
CustomZones: []*mgmProto.CustomZone{
|
||||||
|
{
|
||||||
|
Domain: "corp.internal.",
|
||||||
|
NonAuthoritative: true,
|
||||||
|
Records: []*mgmProto.SimpleRecord{
|
||||||
|
{Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, wgaddr.Address{
|
||||||
|
IP: netip.MustParseAddr("100.64.0.1"),
|
||||||
|
Network: netip.MustParsePrefix("100.64.0.0/16"),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotEmpty(t, config.CustomZones)
|
||||||
|
assert.Equal(t, "corp.internal.", config.CustomZones[0].Domain)
|
||||||
|
assert.False(t, config.CustomZones[0].NonAuthoritative,
|
||||||
|
"a lone zone is forced authoritative for pre-NonAuthoritative servers")
|
||||||
|
|
||||||
|
// The reverse zone the config gains afterwards must not feed back into the
|
||||||
|
// decision: the compat gate counts the zones the server sent.
|
||||||
|
require.Len(t, config.CustomZones, 2, "a reverse zone is appended for the overlay prefix")
|
||||||
|
assert.Equal(t, "64.100.in-addr.arpa.", config.CustomZones[1].Domain)
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func (m *Manager) Start(fwdEntries []*ForwarderEntry) error {
|
|||||||
m.dnsForwarder = NewDNSForwarder(listenAddress, dnsTTL, m.firewall, m.statusRecorder, m.wgIface)
|
m.dnsForwarder = NewDNSForwarder(listenAddress, dnsTTL, m.firewall, m.statusRecorder, m.wgIface)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := m.dnsForwarder.Listen(fwdEntries); err != nil {
|
if err := m.dnsForwarder.Listen(fwdEntries); err != nil { //nolint:staticcheck
|
||||||
// todo handle close error if it is exists
|
// todo handle close error if it is exists
|
||||||
log.Errorf("failed to start DNS forwarder, err: %v", err)
|
log.Errorf("failed to start DNS forwarder, err: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Package elevate re-runs this very executable under the operating system's own
|
||||||
|
// privilege-elevation mechanism and waits for it to finish.
|
||||||
|
//
|
||||||
|
// It exists so that a change the daemon restricts to root/administrator can be
|
||||||
|
// authorized from the GUI, by the user, at the moment they ask for it: Windows
|
||||||
|
// shows the UAC consent dialog, macOS the system authentication dialog, and
|
||||||
|
// Linux/FreeBSD the session's polkit agent. The credentials, where any are
|
||||||
|
// asked for, are collected by the operating system and never pass through
|
||||||
|
// NetBird.
|
||||||
|
//
|
||||||
|
// What the elevated process then does is the caller's business: it is the same
|
||||||
|
// binary, in a one-shot mode, and it is authorized by the daemon exactly like
|
||||||
|
// any other privileged caller, from the identity the kernel reports on the
|
||||||
|
// control channel. Nothing here grants privilege, and the daemon gains no new
|
||||||
|
// way to be talked into something: elevation only changes who is calling it.
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppliedMarker is what the elevated process prints on standard output once it has
|
||||||
|
// done what it was run for.
|
||||||
|
//
|
||||||
|
// macOS's AuthorizationExecuteWithPrivileges reports no exit status and does not
|
||||||
|
// say which process it started, so there this line is the only evidence that the
|
||||||
|
// change was applied. The other platforms have an exit code and ignore it.
|
||||||
|
const AppliedMarker = "netbird-elevated: applied"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrDeclined reports that the user dismissed the prompt or did not
|
||||||
|
// authenticate. Nothing happened and nothing is wrong: a caller undoes its
|
||||||
|
// optimistic update and stays quiet.
|
||||||
|
ErrDeclined = errors.New("authorization declined")
|
||||||
|
|
||||||
|
// ErrUnavailable reports that this host has no elevation mechanism we can
|
||||||
|
// drive: no polkit on a Unix desktop, or an executable we decline to run as
|
||||||
|
// root. A caller falls back to telling the user which command to run.
|
||||||
|
ErrUnavailable = errors.New("no privilege elevation mechanism available")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run runs this executable with args under the platform's elevation mechanism
|
||||||
|
// and waits for it to exit. A non-zero exit is returned as an error, so the
|
||||||
|
// caller can treat a completed Run as the operation having succeeded.
|
||||||
|
//
|
||||||
|
// The args are the caller's own command line, so they cross no privilege
|
||||||
|
// boundary: only a user who has just authenticated as an administrator can get
|
||||||
|
// them run at all.
|
||||||
|
func Run(ctx context.Context, args ...string) error {
|
||||||
|
self, err := trustedSelf()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return run(ctx, self, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available reports whether Run has a mechanism to use on this host, so a caller
|
||||||
|
// can offer the prompt only when there is one and otherwise fall back to
|
||||||
|
// guidance the user can act on. It answers from what is installed, not from what
|
||||||
|
// the user is allowed to do: an administrator's password may still be required
|
||||||
|
// and may still not be given, which is ErrDeclined from Run.
|
||||||
|
func Available() bool {
|
||||||
|
if _, err := trustedSelf(); err != nil {
|
||||||
|
// Worth a line: this is also what a build run from a group-writable
|
||||||
|
// directory hits, and there is nothing in the UI to say why the offer is
|
||||||
|
// missing.
|
||||||
|
log.Debugf("not offering privilege elevation: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mechanismAvailable()
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// noOutput stands in for a process that said nothing, so that a report of what it
|
||||||
|
// said still reads as a sentence.
|
||||||
|
const noOutput = "no output"
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return noOutput
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFirstLine(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{in: "", want: noOutput},
|
||||||
|
{in: " \n ", want: noOutput},
|
||||||
|
{in: "one line", want: "one line"},
|
||||||
|
{in: "first\nsecond", want: "first"},
|
||||||
|
{in: "\nsecond\n", want: "second"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, firstLine(tt.in), "input %q", tt.in)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/ebitengine/purego"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authorization Services, reached through purego rather than cgo so the released
|
||||||
|
// binaries keep building with CGO_ENABLED=0.
|
||||||
|
//
|
||||||
|
// The prompt belongs to this process, which is what makes it carry the
|
||||||
|
// application's name and our own explanation. Going through osascript instead puts
|
||||||
|
// the very same trampoline behind a dialog attributed to osascript, and means
|
||||||
|
// handing a shell a command line to re-parse.
|
||||||
|
//
|
||||||
|
// # On AuthorizationExecuteWithPrivileges
|
||||||
|
//
|
||||||
|
// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on
|
||||||
|
// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's
|
||||||
|
// been deprecated for many years. Do not use it in a widely distributed product."
|
||||||
|
// It is used here anyway, knowingly, because the alternatives Apple offers are for
|
||||||
|
// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless —
|
||||||
|
// and NetBird already has what they would install: a launchd daemon running as
|
||||||
|
// root. What is missing is only a way for an unprivileged client to ask it to act.
|
||||||
|
//
|
||||||
|
// The way to that without a deprecated call is to authorize the client instead of
|
||||||
|
// elevating one: the app takes the right with AuthorizationCreate, passes the
|
||||||
|
// AuthorizationExternalForm to the daemon, and the daemon checks it with
|
||||||
|
// AuthorizationCopyRights before acting — none of which is deprecated. It is the
|
||||||
|
// better design and it is where this should end up. It also means the daemon
|
||||||
|
// accepting an authorization over its control socket, which is a new way to be
|
||||||
|
// asked for privileged work and wants reviewing as such, so it is deliberately not
|
||||||
|
// bundled in with the rest of this.
|
||||||
|
//
|
||||||
|
// Until then, three things keep the deprecation from being a trap. Every symbol is
|
||||||
|
// resolved with an error rather than a panic, so a macOS that has dropped this
|
||||||
|
// function leaves the app offering the user a command instead of crashing on the
|
||||||
|
// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the
|
||||||
|
// fallback is the same one an agent-less Linux session gets. And the whole path
|
||||||
|
// runs under guard, which turns a panic out of the FFI layer into that same
|
||||||
|
// fallback.
|
||||||
|
//
|
||||||
|
// The trampoline passes on the environment it was given, so what it starts as root
|
||||||
|
// must be an executable this user's peers cannot influence: that is what
|
||||||
|
// trustedSelf refuses, and what signing the binary settles for the loader.
|
||||||
|
|
||||||
|
const (
|
||||||
|
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
|
||||||
|
libSystem = "/usr/lib/libSystem.B.dylib"
|
||||||
|
|
||||||
|
// trampoline is what the framework hands the tool to. Present on every macOS,
|
||||||
|
// and worth confirming before offering a prompt rather than mid-prompt.
|
||||||
|
trampoline = "/usr/libexec/security_authtrampoline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rightExecute is the right an administrator holds, and what
|
||||||
|
// AuthorizationExecuteWithPrivileges requires of us.
|
||||||
|
const rightExecute = "system.privilege.admin"
|
||||||
|
|
||||||
|
// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above
|
||||||
|
// the system's in the dialog. It is about the change rather than the mechanism.
|
||||||
|
const (
|
||||||
|
promptKey = "prompt"
|
||||||
|
promptText = "NetBird needs to change a setting that grants SSH access to this computer."
|
||||||
|
)
|
||||||
|
|
||||||
|
// OSStatus values from SecBase.h that mean something to us; anything else is
|
||||||
|
// reported as it comes.
|
||||||
|
const (
|
||||||
|
errAuthorizationSuccess = 0
|
||||||
|
errAuthorizationDenied = -60005
|
||||||
|
errAuthorizationCanceled = -60006
|
||||||
|
errAuthorizationInteractionNotAllowed = -60007
|
||||||
|
errAuthorizationToolExecuteFailure = -60031
|
||||||
|
errAuthorizationToolEnvironmentError = -60032
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthorizationFlags from Authorization.h.
|
||||||
|
const (
|
||||||
|
flagDefaults = 0
|
||||||
|
flagInteractionAllowed = 1 << 0
|
||||||
|
flagExtendRights = 1 << 1
|
||||||
|
flagDestroyRights = 1 << 3
|
||||||
|
flagPreAuthorize = 1 << 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives
|
||||||
|
// meaning to. 32 bytes on both amd64 and arm64.
|
||||||
|
type authorizationItem struct {
|
||||||
|
name *byte
|
||||||
|
valueLength uintptr
|
||||||
|
value unsafe.Pointer
|
||||||
|
// flags is reserved by the API and always zero. Declared because the layout
|
||||||
|
// is the contract: without it the struct is 24 bytes where C reads 32.
|
||||||
|
flags uint32 //nolint:unused // part of the C layout
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an
|
||||||
|
// AuthorizationRights and an AuthorizationEnvironment.
|
||||||
|
type authorizationItemSet struct {
|
||||||
|
count uint32
|
||||||
|
items *authorizationItem
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32
|
||||||
|
authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32
|
||||||
|
authorizationFree func(authorization uintptr, flags uint32) int32
|
||||||
|
fileno func(stream uintptr) int32
|
||||||
|
fclose func(stream uintptr) int32
|
||||||
|
|
||||||
|
loadOnce sync.Once
|
||||||
|
loadErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// load resolves the functions once. A framework that cannot be opened, or a symbol
|
||||||
|
// that is no longer there, leaves the host without a mechanism rather than taking
|
||||||
|
// the process down with it: see the note on deprecation above.
|
||||||
|
func load() error {
|
||||||
|
loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) })
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard turns a panic out of the FFI layer into an error, so an API that has
|
||||||
|
// changed under us costs the user a prompt rather than the window they were
|
||||||
|
// clicking in. purego panics on a signature it cannot map, and this is the one
|
||||||
|
// place in the client that calls a deprecated system function.
|
||||||
|
//
|
||||||
|
// It catches Go panics, which is what purego raises. A fault inside the framework
|
||||||
|
// itself is not a panic and not recoverable; the layout the tests pin down is what
|
||||||
|
// stands between us and that.
|
||||||
|
func guard(what string, fn func() error) (err error) {
|
||||||
|
defer func() {
|
||||||
|
r := recover()
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Errorf("%s panicked: %v", what, r)
|
||||||
|
err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r)
|
||||||
|
}()
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve() error {
|
||||||
|
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", securityFramework, err)
|
||||||
|
}
|
||||||
|
system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", libSystem, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a
|
||||||
|
// deprecated function's disappearance should reach the user.
|
||||||
|
for _, fn := range []struct {
|
||||||
|
ptr any
|
||||||
|
handle uintptr
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{&authorizationCreate, security, "AuthorizationCreate"},
|
||||||
|
{&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"},
|
||||||
|
{&authorizationFree, security, "AuthorizationFree"},
|
||||||
|
{&fileno, system, "fileno"},
|
||||||
|
{&fclose, system, "fclose"},
|
||||||
|
} {
|
||||||
|
symbol, err := purego.Dlsym(fn.handle, fn.name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve %s: %w", fn.name, err)
|
||||||
|
}
|
||||||
|
if symbol == 0 {
|
||||||
|
return fmt.Errorf("resolve %s: not present on this system", fn.name)
|
||||||
|
}
|
||||||
|
purego.RegisterFunc(fn.ptr, symbol)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// run asks the system to run self as root: first for the right, which is what puts
|
||||||
|
// up the authentication dialog and collects the password or takes the Touch ID,
|
||||||
|
// then for the tool. The credentials go to the system's authorization trampoline
|
||||||
|
// and never to us.
|
||||||
|
//
|
||||||
|
// The context bounds only our own waiting; the dialog belongs to the system and
|
||||||
|
// closes when the user answers it.
|
||||||
|
func run(ctx context.Context, self string, args []string) error {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return guard("asking for privileges", func() error {
|
||||||
|
authorization, err := authorize()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer authorizationFree(authorization, flagDestroyRights)
|
||||||
|
|
||||||
|
return execute(ctx, authorization, self, args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(trampoline)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorize obtains the right, prompting for it. A dismissed dialog comes back as
|
||||||
|
// errAuthorizationCanceled and a password given up on as errAuthorizationDenied;
|
||||||
|
// both are the user's answer rather than a failure.
|
||||||
|
func authorize() (uintptr, error) {
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
||||||
|
environment := itemSet(&pinner, promptItem(&pinner))
|
||||||
|
|
||||||
|
var authorization uintptr
|
||||||
|
status := authorizationCreate(rights, environment,
|
||||||
|
flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errAuthorizationSuccess:
|
||||||
|
return authorization, nil
|
||||||
|
case errAuthorizationCanceled, errAuthorizationDenied:
|
||||||
|
return 0, ErrDeclined
|
||||||
|
case errAuthorizationInteractionNotAllowed:
|
||||||
|
// Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or
|
||||||
|
// a session with no window server.
|
||||||
|
return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable)
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute runs the tool with the right in hand and waits for it by reading the pipe
|
||||||
|
// it is given until the tool closes it.
|
||||||
|
//
|
||||||
|
// AuthorizationExecuteWithPrivileges reports no exit status and does not say what
|
||||||
|
// process it started, which is why the one-shot says so itself: what it prints is
|
||||||
|
// the only evidence that the change was applied.
|
||||||
|
func execute(ctx context.Context, authorization uintptr, self string, args []string) error {
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
argv := make([]uintptr, 0, len(args)+1)
|
||||||
|
for _, arg := range args {
|
||||||
|
argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg))))
|
||||||
|
}
|
||||||
|
argv = append(argv, 0)
|
||||||
|
pinner.Pin(&argv[0])
|
||||||
|
|
||||||
|
var pipe uintptr
|
||||||
|
status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe)
|
||||||
|
switch status {
|
||||||
|
case errAuthorizationSuccess:
|
||||||
|
case errAuthorizationCanceled:
|
||||||
|
return ErrDeclined
|
||||||
|
case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError:
|
||||||
|
// The right was granted and the tool still did not start. Nothing the user
|
||||||
|
// can do about it from here, so point them at the command instead.
|
||||||
|
return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("run %s elevated: OSStatus %d", self, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := readPipe(ctx, pipe)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkApplied(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkApplied reads the one-shot's report, which stands in for the exit status
|
||||||
|
// there is no way to ask for here. A run that said nothing did not apply the
|
||||||
|
// change, whatever else went on.
|
||||||
|
func checkApplied(out string) error {
|
||||||
|
if !strings.Contains(out, AppliedMarker) {
|
||||||
|
return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPipe drains the tool's output, which ends when the tool exits and is
|
||||||
|
// therefore also how we wait for it.
|
||||||
|
func readPipe(ctx context.Context, pipe uintptr) (string, error) {
|
||||||
|
if pipe == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
defer fclose(pipe)
|
||||||
|
|
||||||
|
fd := int(fileno(pipe))
|
||||||
|
if fd < 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
n, err := syscall.Read(fd, buf)
|
||||||
|
if n > 0 {
|
||||||
|
out.Write(buf[:n])
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, syscall.EINTR):
|
||||||
|
// A signal landed mid-read, which says nothing about the tool.
|
||||||
|
continue
|
||||||
|
case err != nil:
|
||||||
|
log.Debugf("read the elevated process's output: %v", err)
|
||||||
|
return out.String(), nil
|
||||||
|
case n <= 0:
|
||||||
|
// End of file: the tool closed the pipe, which is how it exiting
|
||||||
|
// reaches us.
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemSet builds an AuthorizationItemSet over items, pinned for the call.
|
||||||
|
func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet {
|
||||||
|
pinner.Pin(&items[0])
|
||||||
|
set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]}
|
||||||
|
pinner.Pin(set)
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptItem is the environment entry carrying our sentence for the dialog.
|
||||||
|
func promptItem(pinner *runtime.Pinner) authorizationItem {
|
||||||
|
value := []byte(promptText)
|
||||||
|
pinner.Pin(&value[0])
|
||||||
|
return authorizationItem{
|
||||||
|
name: cString(pinner, promptKey),
|
||||||
|
valueLength: uintptr(len(value)),
|
||||||
|
value: unsafe.Pointer(&value[0]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for
|
||||||
|
// the duration of the call.
|
||||||
|
func cString(pinner *runtime.Pinner, s string) *byte {
|
||||||
|
b := append([]byte(s), 0)
|
||||||
|
pinner.Pin(&b[0])
|
||||||
|
return &b[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The framework has to load and the symbols have to resolve, or nothing else here
|
||||||
|
// means anything.
|
||||||
|
func TestSecurityFrameworkLoads(t *testing.T) {
|
||||||
|
require.NoError(t, load(), "Security.framework must open")
|
||||||
|
|
||||||
|
for name, fn := range map[string]any{
|
||||||
|
"AuthorizationCreate": authorizationCreate,
|
||||||
|
"AuthorizationExecuteWithPrivileges": authorizationExecuteWithPrivileges,
|
||||||
|
"AuthorizationFree": authorizationFree,
|
||||||
|
"fileno": fileno,
|
||||||
|
"fclose": fclose,
|
||||||
|
} {
|
||||||
|
assert.NotNil(t, fn, "%s must resolve", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request with no interaction allowed exercises the whole call — the rights and
|
||||||
|
// environment structs, and the OSStatus that comes back — without a dialog anybody
|
||||||
|
// has to answer. What the system decides is its business; that it decides at all is
|
||||||
|
// what this asserts.
|
||||||
|
func TestAuthorizationCreateWithoutInteraction(t *testing.T) {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
t.Skipf("Security.framework did not open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
||||||
|
environment := itemSet(&pinner, promptItem(&pinner))
|
||||||
|
require.EqualValues(t, 1, rights.count, "the rights struct layout must match the C one")
|
||||||
|
|
||||||
|
var authorization uintptr
|
||||||
|
status := authorizationCreate(rights, environment, flagDefaults|flagExtendRights, &authorization)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errAuthorizationSuccess:
|
||||||
|
// Credentials were already cached for this session.
|
||||||
|
authorizationFree(authorization, flagDestroyRights)
|
||||||
|
case errAuthorizationDenied, errAuthorizationInteractionNotAllowed:
|
||||||
|
// The expected answers when nobody may be asked.
|
||||||
|
default:
|
||||||
|
require.Failf(t, "unknown OSStatus", "AuthorizationCreate returned %d, want a status we recognise", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asking with a right nobody has must not be mistaken for a declined prompt: the
|
||||||
|
// caller would report nothing at all.
|
||||||
|
func TestAuthorizeUnknownRightIsNotDeclined(t *testing.T) {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
t.Skipf("Security.framework did not open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, "io.netbird.right.that.does.not.exist")})
|
||||||
|
|
||||||
|
var authorization uintptr
|
||||||
|
status := authorizationCreate(rights, nil, flagDefaults|flagExtendRights, &authorization)
|
||||||
|
if status == errAuthorizationSuccess {
|
||||||
|
authorizationFree(authorization, flagDestroyRights)
|
||||||
|
}
|
||||||
|
assert.NotEqual(t, int32(errAuthorizationSuccess), status, "a right that does not exist must not be granted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMechanismAvailable(t *testing.T) {
|
||||||
|
assert.True(t, mechanismAvailable(), "the trampoline exists on every macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-shot's report is what stands in for an exit status here, so a run that
|
||||||
|
// says nothing must not read as success.
|
||||||
|
func TestCheckApplied(t *testing.T) {
|
||||||
|
require.NoError(t, checkApplied(AppliedMarker+"\n"), "the report the one-shot prints")
|
||||||
|
require.NoError(t, checkApplied("some warning\n"+AppliedMarker+"\n"), "the report after other output")
|
||||||
|
|
||||||
|
assert.Error(t, checkApplied(""), "a run that printed nothing did not apply the change")
|
||||||
|
assert.Error(t, checkApplied("dyld: library not loaded\n"), "output that is not the report")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A panic out of the FFI layer has to reach the caller as "no mechanism", which is
|
||||||
|
// the outcome that offers the user the command instead of taking the window down.
|
||||||
|
func TestGuardTurnsAPanicIntoUnavailable(t *testing.T) {
|
||||||
|
err := guard("pretending to call something", func() error {
|
||||||
|
panic("purego: signature it cannot map")
|
||||||
|
})
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, ErrUnavailable, "a panic must read as a missing mechanism")
|
||||||
|
assert.Contains(t, err.Error(), "pretending to call something", "what panicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard wraps every darwin path, so what a caller switches on has to survive it.
|
||||||
|
func TestGuardPassesErrorsThrough(t *testing.T) {
|
||||||
|
sentinel := errors.New("the call itself failed")
|
||||||
|
assert.ErrorIs(t, guard("calling", func() error { return sentinel }), sentinel,
|
||||||
|
"the error it was given")
|
||||||
|
assert.ErrorIs(t, guard("calling", func() error { return ErrDeclined }), ErrDeclined,
|
||||||
|
"a declined prompt stays declined")
|
||||||
|
assert.NoError(t, guard("calling", func() error { return nil }), "a call that worked")
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pkexec exit codes that are about the authorization rather than about the program
|
||||||
|
// we asked it to run. The manual page reserves both.
|
||||||
|
const (
|
||||||
|
// exitDismissed is returned when the user dismissed the authentication
|
||||||
|
// dialog.
|
||||||
|
exitDismissed = 126
|
||||||
|
// exitNotAuthorized is returned when the authorization was not obtained. That
|
||||||
|
// covers the user saying no as well as pkexec having had nobody to ask: see
|
||||||
|
// noAgentMarkers.
|
||||||
|
exitNotAuthorized = 127
|
||||||
|
)
|
||||||
|
|
||||||
|
// exitNotAuthorized covers three different endings that only pkexec's own words
|
||||||
|
// tell apart, so they are matched here. Read with LC_ALL=C so the words are the
|
||||||
|
// ones written below.
|
||||||
|
//
|
||||||
|
// refusedMarker is a refusal: the user said no, gave up on the password, or holds
|
||||||
|
// an account that may not elevate at all.
|
||||||
|
const refusedMarker = "Not authorized"
|
||||||
|
|
||||||
|
// noAgentMarkers say pkexec had no way to ask: no agent registered for the
|
||||||
|
// session, and no controlling terminal for the textual agent it falls back to.
|
||||||
|
var noAgentMarkers = []string{"authentication agent", "controlling terminal"}
|
||||||
|
|
||||||
|
// run asks polkit to run self as root. pkexec hands the request to the session's
|
||||||
|
// polkit agent, which is what prompts and what collects any password; we see only
|
||||||
|
// its verdict.
|
||||||
|
//
|
||||||
|
// The environment is otherwise deliberately not passed through: pkexec clears it
|
||||||
|
// bar a small allowlist, and the one-shot needs nothing from it.
|
||||||
|
func run(ctx context.Context, self string, args []string) error {
|
||||||
|
pkexec, err := exec.LookPath("pkexec")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: pkexec is not installed", ErrUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, pkexec, append([]string{self}, args...)...)
|
||||||
|
// C locale so pkexec's own diagnostics are the ones noAgentMarkers knows.
|
||||||
|
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
// The one-shot reports itself on stdout for macOS's sake, where there is no
|
||||||
|
// exit status to read. Here there is one, so that line is noise.
|
||||||
|
cmd.Stdout = io.Discard
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
return fmt.Errorf("run pkexec: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matched against everything pkexec said, reported as one line: a complaint
|
||||||
|
// that is not the first thing printed still has to be recognised, and reading
|
||||||
|
// it as a refusal would swallow it.
|
||||||
|
full := stderr.String()
|
||||||
|
out := firstLine(full)
|
||||||
|
|
||||||
|
switch exitErr.ExitCode() {
|
||||||
|
case exitDismissed:
|
||||||
|
return ErrDeclined
|
||||||
|
case exitNotAuthorized:
|
||||||
|
return notAuthorized(full, out)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("elevated netbird exited with %d: %s", exitErr.ExitCode(), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notAuthorized sorts out the three endings pkexec reports as exitNotAuthorized.
|
||||||
|
//
|
||||||
|
// It also returns that code when the authorization succeeded and it then could
|
||||||
|
// not run the program, so a refusal has to be recognised rather than assumed:
|
||||||
|
// reading every one of these as "the user said no" would revert the control in
|
||||||
|
// silence on a host where elevation is broken.
|
||||||
|
func notAuthorized(full, out string) error {
|
||||||
|
switch {
|
||||||
|
case hasAny(full, noAgentMarkers):
|
||||||
|
return fmt.Errorf("%w: polkit had no way to ask: %s", ErrUnavailable, out)
|
||||||
|
case out == noOutput, strings.Contains(full, refusedMarker):
|
||||||
|
// The user said no, which needs no message; that an account barred from
|
||||||
|
// elevating altogether lands here too is why the reason is kept.
|
||||||
|
return fmt.Errorf("%w: %s", ErrDeclined, out)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("pkexec could not run elevated netbird: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAny(s string, markers []string) bool {
|
||||||
|
for _, marker := range markers {
|
||||||
|
if strings.Contains(s, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
_, err := exec.LookPath("pkexec")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePkexec puts a pkexec on PATH that exits with the given code, so the
|
||||||
|
// mapping from polkit's exit codes onto our errors can be exercised without a
|
||||||
|
// polkit agent.
|
||||||
|
func fakePkexec(t *testing.T, exitCode int, stderr string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
script := fmt.Sprintf("#!/bin/sh\necho %s >&2\nexit %d\n", shellQuote(stderr), exitCode)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "pkexec"), []byte(script), 0o700), "write the fake pkexec")
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMapsPkexecExitCodes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
exitCode int
|
||||||
|
stderr string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{name: "applied", exitCode: 0},
|
||||||
|
{
|
||||||
|
name: "dialog dismissed",
|
||||||
|
exitCode: exitDismissed,
|
||||||
|
stderr: "Error executing command as another user: Request dismissed",
|
||||||
|
wantErr: ErrDeclined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// What a graphical agent reports for a cancelled prompt. Not a
|
||||||
|
// failure: the user was asked and answered.
|
||||||
|
name: "prompt cancelled",
|
||||||
|
exitCode: exitNotAuthorized,
|
||||||
|
stderr: "Error executing command as another user: Not authorized",
|
||||||
|
wantErr: ErrDeclined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The same status, but pkexec never got to ask anybody.
|
||||||
|
name: "no agent and no terminal to fall back on",
|
||||||
|
exitCode: exitNotAuthorized,
|
||||||
|
stderr: "Error creating textual authentication agent: Error opening current controlling terminal for the process (`/dev/tty'): No such device or address",
|
||||||
|
wantErr: ErrUnavailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// And the same status again once the authorization succeeded and
|
||||||
|
// pkexec could not run what it had been authorized to run. Reading
|
||||||
|
// that as a refusal would revert the control in silence on a host
|
||||||
|
// where elevation is broken.
|
||||||
|
name: "authorized but not runnable",
|
||||||
|
exitCode: exitNotAuthorized,
|
||||||
|
stderr: "Error executing command as another user: No such file or directory",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fakePkexec(t, tt.exitCode, tt.stderr)
|
||||||
|
|
||||||
|
err := run(context.Background(), "/nonexistent/netbird-ui", []string{"--flag"})
|
||||||
|
switch {
|
||||||
|
case tt.wantErr != nil:
|
||||||
|
require.ErrorIs(t, err, tt.wantErr, "exit %d said %q", tt.exitCode, tt.stderr)
|
||||||
|
case tt.exitCode == 0:
|
||||||
|
require.NoError(t, err, "a pkexec that exited cleanly applied the change")
|
||||||
|
default:
|
||||||
|
require.Error(t, err, "exit %d said %q", tt.exitCode, tt.stderr)
|
||||||
|
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
|
||||||
|
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exit code that is not polkit's is the one-shot's own failure, and has to
|
||||||
|
// stay distinguishable from a declined prompt: the caller reports it.
|
||||||
|
func TestRunReportsOneShotFailure(t *testing.T) {
|
||||||
|
fakePkexec(t, 3, "the one-shot said no")
|
||||||
|
|
||||||
|
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
|
||||||
|
|
||||||
|
require.Error(t, err, "a one-shot that failed is not a prompt that was answered")
|
||||||
|
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
|
||||||
|
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithoutPkexecIsUnavailable(t *testing.T) {
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
|
||||||
|
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
|
||||||
|
require.ErrorIs(t, err, ErrUnavailable, "no pkexec means no mechanism")
|
||||||
|
assert.False(t, mechanismAvailable(), "mechanismAvailable without pkexec on PATH")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows && !darwin && !linux
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// run reports that this platform has no elevation prompt to drive.
|
||||||
|
//
|
||||||
|
// The desktop app is the only caller and is not built for any of these: mobile
|
||||||
|
// and WASM have no local user to ask, and the FreeBSD client ships without a UI.
|
||||||
|
// pkexec would be the mechanism there, and run_unix.go is what to widen if that
|
||||||
|
// changes.
|
||||||
|
func run(context.Context, string, []string) error {
|
||||||
|
return ErrUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// seeMaskNoCloseProcess keeps the started process's handle open in
|
||||||
|
// hProcess so we can wait for it.
|
||||||
|
seeMaskNoCloseProcess = 0x00000040
|
||||||
|
// seeMaskNoAsync makes ShellExecuteExW finish its work before returning,
|
||||||
|
// which it must when the calling thread does not pump messages.
|
||||||
|
seeMaskNoAsync = 0x00000100
|
||||||
|
// seeMaskFlagNoUI suppresses the shell's own error dialogs; the UAC consent
|
||||||
|
// dialog is not one of them and still appears.
|
||||||
|
seeMaskFlagNoUI = 0x00000400
|
||||||
|
|
||||||
|
// swHide: the one-shot has no window to show.
|
||||||
|
swHide = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// shellExecuteInfoW mirrors SHELLEXECUTEINFOW. The field order and Go's own
|
||||||
|
// padding match the C layout on both 386 and amd64.
|
||||||
|
type shellExecuteInfoW struct {
|
||||||
|
cbSize uint32
|
||||||
|
fMask uint32
|
||||||
|
hwnd windows.HWND
|
||||||
|
lpVerb *uint16
|
||||||
|
lpFile *uint16
|
||||||
|
lpParameters *uint16
|
||||||
|
lpDirectory *uint16
|
||||||
|
nShow int32
|
||||||
|
hInstApp windows.Handle
|
||||||
|
lpIDList uintptr
|
||||||
|
lpClass *uint16
|
||||||
|
hkeyClass windows.Handle
|
||||||
|
dwHotKey uint32
|
||||||
|
hIconOrMonitor windows.Handle
|
||||||
|
hProcess windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
shell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||||
|
procShellExecuteEx = shell32.NewProc("ShellExecuteExW")
|
||||||
|
)
|
||||||
|
|
||||||
|
// run starts self elevated with the "runas" verb, which is what raises the UAC
|
||||||
|
// consent dialog, and waits for it to finish. Windows decides whether consent is
|
||||||
|
// enough or an administrator's credentials are needed, and collects them itself.
|
||||||
|
func run(ctx context.Context, self string, args []string) error {
|
||||||
|
verb, err := windows.UTF16PtrFromString("runas")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode verb: %w", err)
|
||||||
|
}
|
||||||
|
file, err := windows.UTF16PtrFromString(self)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode %s: %w", self, err)
|
||||||
|
}
|
||||||
|
params, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := shellExecuteInfoW{
|
||||||
|
fMask: seeMaskNoCloseProcess | seeMaskNoAsync | seeMaskFlagNoUI,
|
||||||
|
hwnd: ownerWindow(),
|
||||||
|
lpVerb: verb,
|
||||||
|
lpFile: file,
|
||||||
|
lpParameters: params,
|
||||||
|
nShow: swHide,
|
||||||
|
}
|
||||||
|
info.cbSize = uint32(unsafe.Sizeof(info))
|
||||||
|
|
||||||
|
process, err := shellExecute(&info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := windows.CloseHandle(process); err != nil {
|
||||||
|
log.Debugf("close elevated process handle: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return waitForProcess(ctx, process)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellExecute performs the call itself. ShellExecuteExW wants COM initialised on
|
||||||
|
// the calling thread, so the goroutine is pinned to one for the duration and COM
|
||||||
|
// is set up on it; an "already initialised, different mode" answer is fine,
|
||||||
|
// because then somebody else has done it for us.
|
||||||
|
func shellExecute(info *shellExecuteInfoW) (windows.Handle, error) {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
|
||||||
|
switch err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); {
|
||||||
|
case err == nil, isHResult(err, windows.S_FALSE):
|
||||||
|
// Ours, or already initialised in the same mode: either way this call
|
||||||
|
// counts and has to be balanced.
|
||||||
|
defer windows.CoUninitialize()
|
||||||
|
case isHResult(err, windows.RPC_E_CHANGED_MODE):
|
||||||
|
// The thread is already in the other apartment model. ShellExecuteExW
|
||||||
|
// works there too, and there is nothing of ours to balance.
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("initialise COM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ret, _, lastErr := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info)))
|
||||||
|
if ret != 0 {
|
||||||
|
return info.hProcess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(lastErr, windows.ERROR_CANCELLED) {
|
||||||
|
return 0, ErrDeclined
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("run elevated: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerWindow returns this process's foreground window, and 0 when the window in
|
||||||
|
// front belongs to somebody else or cannot be attributed. ShellExecuteExW takes it
|
||||||
|
// as the parent for the UI it raises, which is what keeps the consent dialog in
|
||||||
|
// front of the window the user was just clicking in instead of behind it. It is
|
||||||
|
// also what a remote-desktop session needs to place the dialog at all when the
|
||||||
|
// secure desktop is switched off.
|
||||||
|
func ownerWindow() windows.HWND {
|
||||||
|
hwnd := windows.GetForegroundWindow()
|
||||||
|
if hwnd == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var pid uint32
|
||||||
|
if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil {
|
||||||
|
log.Debugf("cannot attribute the foreground window, raising the prompt without an owner: %v", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if pid != windows.GetCurrentProcessId() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return hwnd
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHResult reports whether err carries the given HRESULT. CoInitializeEx
|
||||||
|
// returns its HRESULT as an Errno, so the comparison is on the raw value.
|
||||||
|
func isHResult(err error, hresult windows.Handle) bool {
|
||||||
|
var errno windows.Errno
|
||||||
|
return errors.As(err, &errno) && uintptr(errno) == uintptr(hresult)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForProcess(ctx context.Context, process windows.Handle) error {
|
||||||
|
// The wait is interruptible so a cancelled context stops us waiting on a
|
||||||
|
// consent dialog nobody is going to answer. The elevated process is not
|
||||||
|
// ours to kill, and it either applies the change or does not.
|
||||||
|
for {
|
||||||
|
event, err := windows.WaitForSingleObject(process, 250)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wait for the elevated process: %w", err)
|
||||||
|
}
|
||||||
|
if event == uint32(windows.WAIT_OBJECT_0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var code uint32
|
||||||
|
if err := windows.GetExitCodeProcess(process, &code); err != nil {
|
||||||
|
return fmt.Errorf("read the elevated process's exit code: %w", err)
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
return fmt.Errorf("elevated netbird exited with %d", code)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mechanismAvailable is true on Windows: UAC prompts for consent when the user
|
||||||
|
// is an administrator and for an administrator's credentials when they are not,
|
||||||
|
// so there is always something to ask.
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trustedSelf returns the path of this executable, provided it is one we are
|
||||||
|
// willing to have run as root.
|
||||||
|
//
|
||||||
|
// The check is what keeps elevation from becoming a way to launder someone
|
||||||
|
// else's code into a root process: the user consents to NetBird being elevated,
|
||||||
|
// having been shown NetBird's name, so what runs must be the file NetBird was
|
||||||
|
// installed as and not something a third party could have swapped for it. An
|
||||||
|
// executable only its owner can write is that; anything wider is refused, and
|
||||||
|
// the caller falls back to showing the command instead.
|
||||||
|
//
|
||||||
|
// The owner writing to their own executable is not part of that threat: code
|
||||||
|
// running as the user can already prompt them for anything, and could just as
|
||||||
|
// well ask them to run the command by hand. What matters is that no *other*
|
||||||
|
// unprivileged account can reach it.
|
||||||
|
func trustedSelf() (string, error) {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("locate this executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve symlinks so the checks below apply to the file that would actually
|
||||||
|
// be executed, not to a link somebody else may control.
|
||||||
|
resolved, err := filepath.EvalSymlinks(exe)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve %s: %w", exe, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkOnlyOwnerWritable(resolved); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %s cannot be trusted to run as root: %w", ErrUnavailable, resolved, err)
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
// adminWriteGIDs are the groups whose write access to an executable does not
|
||||||
|
// widen who could authorize elevating it.
|
||||||
|
//
|
||||||
|
// macOS installs applications as root:admin, mode 0775, /Applications included,
|
||||||
|
// so requiring owner-only write would reject every normal install. Group admin
|
||||||
|
// (gid 80) is exactly the set of accounts that can answer the authentication
|
||||||
|
// dialog, so its write access grants nothing the prompt would not.
|
||||||
|
var adminWriteGIDs = []uint32{0, 80}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
// adminWriteGIDs are the groups whose write access to an executable does not
|
||||||
|
// widen who could authorize elevating it. Only root's own group qualifies here:
|
||||||
|
// a distribution installs into root-owned directories, and there is no
|
||||||
|
// system-wide administrators group that both writes them and answers polkit.
|
||||||
|
var adminWriteGIDs = []uint32{0}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/getent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkOnlyOwnerWritable reports an error unless path, and every directory leading
|
||||||
|
// to it, is owned by either root or this user and writable by nobody who could not
|
||||||
|
// already act as its owner. A writable directory is as good as a writable file,
|
||||||
|
// since anything in it can be replaced, so the whole chain is checked.
|
||||||
|
func checkOnlyOwnerWritable(path string) error {
|
||||||
|
self := uint32(os.Getuid())
|
||||||
|
|
||||||
|
for dir := path; ; dir = filepath.Dir(dir) {
|
||||||
|
info, err := os.Lstat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("file ownership is unavailable on this platform")
|
||||||
|
}
|
||||||
|
if stat.Uid != 0 && stat.Uid != self {
|
||||||
|
return fmt.Errorf("%s is owned by uid %d, neither root nor this user", dir, stat.Uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkWriteBits(dir, info, stat.Uid, stat.Gid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent := filepath.Dir(dir); parent == dir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkWriteBits(path string, info os.FileInfo, uid, gid uint32) error {
|
||||||
|
// On a directory the sticky bit stands in for the write bits: whoever may
|
||||||
|
// write there still cannot replace an entry they do not own, which is the
|
||||||
|
// only thing that would matter to us. /tmp is the usual example.
|
||||||
|
sticky := info.IsDir() && info.Mode()&os.ModeSticky != 0
|
||||||
|
|
||||||
|
return writeBitsAllow(path, info.Mode().Perm(), sticky, groupWriteAllowed(uid, gid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBitsAllow decides on the permission bits alone, given whether the group's
|
||||||
|
// write access has been vouched for.
|
||||||
|
func writeBitsAllow(path string, perm os.FileMode, sticky, groupAllowed bool) error {
|
||||||
|
if sticky {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if perm&0o020 != 0 && !groupAllowed {
|
||||||
|
return fmt.Errorf("%s is writable by a group with members other than its owner (%v)", path, perm)
|
||||||
|
}
|
||||||
|
if perm&0o002 != 0 {
|
||||||
|
return fmt.Errorf("%s is world-writable (%v)", path, perm)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupWriteAllowed reports whether a group's write access to a file owned by uid
|
||||||
|
// puts it in reach of anyone who could not already act as that owner.
|
||||||
|
//
|
||||||
|
// Two ways it does not. A group in adminWriteGIDs holds the accounts that can
|
||||||
|
// answer the elevation prompt anyway. And a user private group is how Debian,
|
||||||
|
// Ubuntu and Fedora ship: their umask of 002 makes a home directory and
|
||||||
|
// everything built in it group-writable, so refusing that would refuse every
|
||||||
|
// build not installed from a package.
|
||||||
|
func groupWriteAllowed(uid, gid uint32) bool {
|
||||||
|
if slices.Contains(adminWriteGIDs, gid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := getent.LookupGroupID(strconv.FormatUint(uint64(gid), 10))
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("cannot look up group %d, treating it as shared: %v", gid, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
owner, err := getent.LookupUserID(strconv.FormatUint(uint64(uid), 10))
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("cannot look up uid %d, treating its group as shared: %v", uid, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if group.Name != owner.Username {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !groupHasOtherMembers(group.Name, owner.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupHasOtherMembers reports whether the group lists a member besides owner.
|
||||||
|
//
|
||||||
|
// Sharing the owner's name is what a user private group is recognised by, and it
|
||||||
|
// says nothing about who is in it: a group that has since gained a member is
|
||||||
|
// still named that way, and that member can write whatever the group can. So the
|
||||||
|
// membership is read rather than assumed. A group whose members cannot be
|
||||||
|
// listed, because no source on this host describes it, is treated as shared:
|
||||||
|
// the name alone cannot vouch for who writes through it.
|
||||||
|
func groupHasOtherMembers(name, owner string) bool {
|
||||||
|
members, err := getent.GroupMembers(name)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("cannot list the members of group %q, treating it as shared: %v", name, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(members, func(member string) bool { return member != owner })
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its
|
||||||
|
// numbered directory with 0777 minus the umask, so under the common 002 umask it
|
||||||
|
// is group-writable and would fail the check under test on its own.
|
||||||
|
func ownerOnlyDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory")
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeExecutable creates a plain executable file, the shape trustedSelf checks.
|
||||||
|
func writeExecutable(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, "netbird-ui")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable")
|
||||||
|
require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) {
|
||||||
|
err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t)))
|
||||||
|
assert.NoError(t, err, "an owner-only writable executable is trustworthy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) {
|
||||||
|
path := writeExecutable(t, ownerOnlyDir(t))
|
||||||
|
require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable")
|
||||||
|
|
||||||
|
assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The permission policy on its own, without a filesystem to arrange: whether the
|
||||||
|
// group has been vouched for is the only thing that makes group write acceptable.
|
||||||
|
func TestWriteBitsAllow(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
perm os.FileMode
|
||||||
|
sticky bool
|
||||||
|
groupAllowed bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "owner only", perm: 0o755},
|
||||||
|
{name: "group write in a private group", perm: 0o775, groupAllowed: true},
|
||||||
|
{name: "group write in a shared group", perm: 0o775, wantErr: true},
|
||||||
|
{name: "world write", perm: 0o777, groupAllowed: true, wantErr: true},
|
||||||
|
{name: "world write on a sticky directory", perm: 0o777, sticky: true},
|
||||||
|
{name: "group write on a sticky directory", perm: 0o775, sticky: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A build under a home directory on a distribution with a 002 umask, which is what
|
||||||
|
// a locally built or tarball-installed binary looks like. Its group has no members
|
||||||
|
// but its owner, so it is as good as owner-only.
|
||||||
|
//
|
||||||
|
// Whether this host is such a distribution is read from the environment rather than
|
||||||
|
// from groupWriteAllowed: asking the function under test whether to run would let
|
||||||
|
// it skip its own coverage away if it regressed to refusing everything.
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) {
|
||||||
|
requirePrivatePrimaryGroup(t)
|
||||||
|
|
||||||
|
dir := ownerOnlyDir(t)
|
||||||
|
path := writeExecutable(t, dir)
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable")
|
||||||
|
require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable")
|
||||||
|
|
||||||
|
err := checkOnlyOwnerWritable(path)
|
||||||
|
assert.NoError(t, err, "group write in the owner's own private group reaches nobody else")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group whose membership no source can answer for is treated as shared: the
|
||||||
|
// private-group allowance must not stand on a name nobody can vouch for. The
|
||||||
|
// membership listing itself lives in the getent package and is tested there.
|
||||||
|
func TestGroupHasOtherMembersRejectsAnUnknownGroup(t *testing.T) {
|
||||||
|
assert.True(t, groupHasOtherMembers("nonexistent_group_xyzzy_12345", "vma"),
|
||||||
|
"a group no source describes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A writable directory is as good as a writable file: whoever can write the
|
||||||
|
// directory can put a different binary at the same path.
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) {
|
||||||
|
dir := filepath.Join(ownerOnlyDir(t), "bin")
|
||||||
|
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
|
||||||
|
path := writeExecutable(t, dir)
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable")
|
||||||
|
|
||||||
|
assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sticky world-writable directory is exempt: the sticky bit is what stops one
|
||||||
|
// user replacing another's entries. /tmp is why this matters.
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) {
|
||||||
|
dir := filepath.Join(ownerOnlyDir(t), "sticky")
|
||||||
|
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
|
||||||
|
path := writeExecutable(t, dir)
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable")
|
||||||
|
|
||||||
|
err := checkOnlyOwnerWritable(path)
|
||||||
|
assert.NoError(t, err, "the sticky bit stops another user replacing the executable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) {
|
||||||
|
err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent"))
|
||||||
|
assert.Error(t, err, "an executable that is not there must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// requirePrivatePrimaryGroup skips unless this user's primary group is their own,
|
||||||
|
// which is what the user-private-group allowance is about.
|
||||||
|
func requirePrivatePrimaryGroup(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
self, err := user.Current()
|
||||||
|
require.NoError(t, err, "look up the test user")
|
||||||
|
group, err := user.LookupGroupId(strconv.Itoa(os.Getgid()))
|
||||||
|
require.NoError(t, err, "look up the test user's primary group")
|
||||||
|
|
||||||
|
if group.Name != self.Username {
|
||||||
|
t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name)
|
||||||
|
}
|
||||||
|
if groupHasOtherMembers(group.Name, self.Username) {
|
||||||
|
t.Skipf("group %q has other members, so it is not a private group", group.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// fileDeleteChild is FILE_DELETE_CHILD, which x/sys does not define: the
|
||||||
|
// right to delete an entry of a directory without holding DELETE on it.
|
||||||
|
fileDeleteChild = 0x00000040
|
||||||
|
|
||||||
|
// accessAllowedCallbackACEType is an allow ACE with a condition appended to
|
||||||
|
// the ACCESS_ALLOWED_ACE layout, so its trustee is still at SidStart.
|
||||||
|
accessAllowedCallbackACEType = 0x9
|
||||||
|
|
||||||
|
// The allow ACE types that carry object GUIDs ahead of the trustee, so the
|
||||||
|
// SID is not at SidStart. They occur on directory-service objects rather
|
||||||
|
// than files, and are refused rather than skipped: see aceTrustee.
|
||||||
|
accessAllowedObjectACEType = 0x5
|
||||||
|
accessAllowedCallbackObjectACEType = 0xB
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileWriteAccess are the rights that let a trustee rewrite or replace a file,
|
||||||
|
// or take it over and then do so.
|
||||||
|
const fileWriteAccess = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA |
|
||||||
|
windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER |
|
||||||
|
windows.GENERIC_WRITE | windows.GENERIC_ALL
|
||||||
|
|
||||||
|
// dirWriteAccess are the rights over a directory that let a trustee replace an
|
||||||
|
// entry somebody else owns. Creating a new entry is not one of them, which is
|
||||||
|
// what the Unix sticky bit says in one bit: the root of every volume grants
|
||||||
|
// BUILTIN\Users the right to add directories under it, and that reaches nothing
|
||||||
|
// already there.
|
||||||
|
const dirWriteAccess = fileDeleteChild | windows.DELETE |
|
||||||
|
windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_ALL
|
||||||
|
|
||||||
|
// trustedInstallerSID owns much of what Windows itself installs. x/sys has no
|
||||||
|
// well-known constant for it.
|
||||||
|
const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
|
||||||
|
|
||||||
|
// checkOnlyOwnerWritable reports an error unless path, and every directory
|
||||||
|
// leading to it, is owned by an account that can elevate (or by this user) and
|
||||||
|
// grants write access to nobody else. A writable directory is as good as a
|
||||||
|
// writable file, since an entry in it can be replaced, so the whole chain is
|
||||||
|
// checked.
|
||||||
|
func checkOnlyOwnerWritable(path string) error {
|
||||||
|
owners, err := trustedOwners()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writers, err := trustedWriters(owners)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAccess := windows.ACCESS_MASK(fileWriteAccess)
|
||||||
|
for target := path; ; target = filepath.Dir(target) {
|
||||||
|
if err := checkSecurity(target, writeAccess, owners, writers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if parent := filepath.Dir(target); parent == target {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writeAccess = dirWriteAccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedOwners are the accounts we accept as the owner of the executable and of
|
||||||
|
// the directories above it: the ones that can already answer the UAC prompt,
|
||||||
|
// plus this user, whose own executable is theirs to write. Code running as the
|
||||||
|
// user could prompt them for anything anyway; what matters is that no *other*
|
||||||
|
// unprivileged account can reach it.
|
||||||
|
func trustedOwners() ([]*windows.SID, error) {
|
||||||
|
self, err := currentUserSID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owners := []*windows.SID{self}
|
||||||
|
for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{
|
||||||
|
windows.WinLocalSystemSid,
|
||||||
|
windows.WinBuiltinAdministratorsSid,
|
||||||
|
} {
|
||||||
|
sid, err := windows.CreateWellKnownSid(wellKnown)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build well-known SID %d: %w", wellKnown, err)
|
||||||
|
}
|
||||||
|
owners = append(owners, sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
installer, err := windows.StringToSid(trustedInstallerSID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse TrustedInstaller SID: %w", err)
|
||||||
|
}
|
||||||
|
return append(owners, installer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedWriters are the trustees whose write access does not widen who could
|
||||||
|
// decide what runs behind the prompt. The owners, and CREATOR OWNER, which
|
||||||
|
// resolves to the object's owner and is therefore already vetted.
|
||||||
|
func trustedWriters(owners []*windows.SID) ([]*windows.SID, error) {
|
||||||
|
creatorOwner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build the CREATOR OWNER SID: %w", err)
|
||||||
|
}
|
||||||
|
return append(slices.Clone(owners), creatorOwner), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSecurity(path string, writeAccess windows.ACCESS_MASK, owners, writers []*windows.SID) error {
|
||||||
|
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||||
|
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read security descriptor of %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, _, err := sd.Owner()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read owner of %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if !containsSID(owners, owner) {
|
||||||
|
return fmt.Errorf("%s is owned by %s, which is neither this user nor an account that can elevate", path, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
dacl, _, err := sd.DACL()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read DACL of %s: %w", path, err)
|
||||||
|
}
|
||||||
|
// A NULL DACL grants everyone everything; only an absent security
|
||||||
|
// descriptor would have got us here without one, and neither is trustworthy.
|
||||||
|
if dacl == nil {
|
||||||
|
return fmt.Errorf("%s has no DACL, so it grants write access to everyone", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkDACL(path, dacl, writeAccess, writers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDACL refuses an ACL that grants write access to a trustee outside
|
||||||
|
// writers.
|
||||||
|
//
|
||||||
|
// An allowlist, because the trustees that must not have it cannot be listed: an
|
||||||
|
// ACE naming an ordinary user account hands that account the same power as one
|
||||||
|
// naming Everyone, and only the accounts that may hold it are knowable.
|
||||||
|
func checkDACL(path string, dacl *windows.ACL, writeAccess windows.ACCESS_MASK, writers []*windows.SID) error {
|
||||||
|
for i := uint32(0); i < uint32(dacl.AceCount); i++ {
|
||||||
|
var ace *windows.ACCESS_ALLOWED_ACE
|
||||||
|
if err := windows.GetAce(dacl, i, &ace); err != nil {
|
||||||
|
return fmt.Errorf("read ACE %d of %s: %w", i, path, err)
|
||||||
|
}
|
||||||
|
// An inherit-only ACE says what children of this object get, not what
|
||||||
|
// this object grants.
|
||||||
|
if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ace.Mask&writeAccess == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only an allow ACE grants anything; a deny ACE narrows what one gave.
|
||||||
|
if !isAllowACE(ace.Header.AceType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
trustee, err := aceTrustee(ace)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read the trustee of ACE %d of %s: %w", i, path, err)
|
||||||
|
}
|
||||||
|
if !containsSID(writers, trustee) {
|
||||||
|
return fmt.Errorf("%s grants write access to %s", path, trustee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowACE reports whether an ACE type grants rights, rather than denying,
|
||||||
|
// auditing or labelling them.
|
||||||
|
func isAllowACE(aceType uint8) bool {
|
||||||
|
switch aceType {
|
||||||
|
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType,
|
||||||
|
accessAllowedObjectACEType, accessAllowedCallbackObjectACEType:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aceTrustee returns who an allow ACE grants its rights to. An ACE whose trustee
|
||||||
|
// cannot be located is an error rather than something to skip past: being unable
|
||||||
|
// to read who is being given write access is a refusal.
|
||||||
|
func aceTrustee(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) {
|
||||||
|
switch ace.Header.AceType {
|
||||||
|
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType:
|
||||||
|
//nolint:gosec // SidStart is the first uint32 of the variable-length SID that follows the ACE header.
|
||||||
|
return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), nil
|
||||||
|
default:
|
||||||
|
return nil, errors.New("an object-type allow ACE does not carry its trustee where we can read it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSID(sids []*windows.SID, sid *windows.SID) bool {
|
||||||
|
return slices.ContainsFunc(sids, sid.Equals)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUserSID() (*windows.SID, error) {
|
||||||
|
token := windows.GetCurrentProcessToken()
|
||||||
|
user, err := token.GetTokenUser()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read this process's user: %w", err)
|
||||||
|
}
|
||||||
|
return user.User.Sid, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A file the test user created under their own profile, which is what a per-user
|
||||||
|
// install looks like. The whole chain up to the volume root is walked, so this is
|
||||||
|
// also what says the walk does not refuse an ordinary Windows installation: the
|
||||||
|
// root of every volume grants BUILTIN\Users rights that are not ours to worry
|
||||||
|
// about.
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsOwnFile(t *testing.T) {
|
||||||
|
err := checkOnlyOwnerWritable(writeExecutable(t))
|
||||||
|
assert.NoError(t, err, "a file the test user owns, under directories only administrators can write")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write access held by an account that cannot answer the UAC prompt means that
|
||||||
|
// account decides what runs behind it, whoever the ACE names. The trustees that
|
||||||
|
// must not have it cannot be listed, so the check names the ones that may.
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsUntrustedWriters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wellKnown windows.WELL_KNOWN_SID_TYPE
|
||||||
|
}{
|
||||||
|
{name: "everyone", wellKnown: windows.WinWorldSid},
|
||||||
|
{name: "authenticated users", wellKnown: windows.WinAuthenticatedUserSid},
|
||||||
|
{name: "builtin users", wellKnown: windows.WinBuiltinUsersSid},
|
||||||
|
// A service account, which no denylist of the obvious groups would name
|
||||||
|
// and which cannot elevate any more than Everyone can.
|
||||||
|
{name: "local service", wellKnown: windows.WinLocalServiceSid},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := writeExecutable(t)
|
||||||
|
grantWrite(t, path, tt.wellKnown)
|
||||||
|
|
||||||
|
assert.Error(t, checkOnlyOwnerWritable(path),
|
||||||
|
"write access for %s must be refused", tt.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The masks are the policy: on a file any write reaches its contents, while on a
|
||||||
|
// directory only deleting or taking over an entry reaches something already
|
||||||
|
// there. Adding an entry does not, which is why the walk survives a volume root.
|
||||||
|
func TestWriteAccessMasks(t *testing.T) {
|
||||||
|
assert.NotZero(t, fileWriteAccess&windows.FILE_WRITE_DATA, "writing a file's data reaches its contents")
|
||||||
|
assert.NotZero(t, fileWriteAccess&windows.FILE_APPEND_DATA, "appending to a file reaches its contents")
|
||||||
|
|
||||||
|
assert.Zero(t, dirWriteAccess&windows.FILE_WRITE_DATA, "adding a file to a directory replaces nothing")
|
||||||
|
assert.Zero(t, dirWriteAccess&windows.FILE_APPEND_DATA, "adding a subdirectory replaces nothing")
|
||||||
|
assert.NotZero(t, dirWriteAccess&fileDeleteChild, "deleting an entry replaces it")
|
||||||
|
assert.NotZero(t, dirWriteAccess&windows.DELETE, "deleting the directory takes its entries with it")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowACE(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
aceType uint8
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, want: true},
|
||||||
|
{name: "allowed callback", aceType: accessAllowedCallbackACEType, want: true},
|
||||||
|
{name: "allowed object", aceType: accessAllowedObjectACEType, want: true},
|
||||||
|
{name: "allowed callback object", aceType: accessAllowedCallbackObjectACEType, want: true},
|
||||||
|
{name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE},
|
||||||
|
// SYSTEM_AUDIT_ACE_TYPE, which x/sys does not define: an ACE that records
|
||||||
|
// access rather than granting it.
|
||||||
|
{name: "audit", aceType: 0x2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, isAllowACE(tt.aceType), "ACE type %#x", tt.aceType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeExecutable creates a plain file under the test's own directory, the shape
|
||||||
|
// trustedSelf checks.
|
||||||
|
func writeExecutable(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "netbird-ui.exe")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("MZ"), 0o755), "write the executable")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantWrite replaces the file's DACL with one that grants a well-known trustee
|
||||||
|
// everything, keeping the test user's own access so the file stays deletable.
|
||||||
|
func grantWrite(t *testing.T, path string, wellKnown windows.WELL_KNOWN_SID_TYPE) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
trustee, err := windows.CreateWellKnownSid(wellKnown)
|
||||||
|
require.NoError(t, err, "build the trustee SID")
|
||||||
|
self, err := currentUserSID()
|
||||||
|
require.NoError(t, err, "read the test user's SID")
|
||||||
|
|
||||||
|
acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{
|
||||||
|
fullControl(self, windows.TRUSTEE_IS_USER),
|
||||||
|
fullControl(trustee, windows.TRUSTEE_IS_WELL_KNOWN_GROUP),
|
||||||
|
}, nil)
|
||||||
|
require.NoError(t, err, "build the ACL")
|
||||||
|
|
||||||
|
require.NoError(t, windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||||
|
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||||
|
nil, nil, acl, nil), "set the DACL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullControl(sid *windows.SID, trusteeType uint32) windows.EXPLICIT_ACCESS {
|
||||||
|
return windows.EXPLICIT_ACCESS{
|
||||||
|
AccessPermissions: windows.GENERIC_ALL,
|
||||||
|
AccessMode: windows.GRANT_ACCESS,
|
||||||
|
Trustee: windows.TRUSTEE{
|
||||||
|
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||||
|
TrusteeType: windows.TRUSTEE_TYPE(trusteeType),
|
||||||
|
TrusteeValue: windows.TrusteeValueFromSID(sid),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-49
@@ -59,6 +59,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/syncstore"
|
"github.com/netbirdio/netbird/client/internal/syncstore"
|
||||||
"github.com/netbirdio/netbird/client/internal/updater"
|
"github.com/netbirdio/netbird/client/internal/updater"
|
||||||
"github.com/netbirdio/netbird/client/jobexec"
|
"github.com/netbirdio/netbird/client/jobexec"
|
||||||
|
"github.com/netbirdio/netbird/client/netevents"
|
||||||
cProto "github.com/netbirdio/netbird/client/proto"
|
cProto "github.com/netbirdio/netbird/client/proto"
|
||||||
"github.com/netbirdio/netbird/client/system"
|
"github.com/netbirdio/netbird/client/system"
|
||||||
nbdns "github.com/netbirdio/netbird/dns"
|
nbdns "github.com/netbirdio/netbird/dns"
|
||||||
@@ -136,6 +137,7 @@ type EngineConfig struct {
|
|||||||
RosenpassPermissive bool
|
RosenpassPermissive bool
|
||||||
|
|
||||||
ServerSSHAllowed bool
|
ServerSSHAllowed bool
|
||||||
|
RemoteJobsAllowed bool
|
||||||
EnableSSHRoot *bool
|
EnableSSHRoot *bool
|
||||||
EnableSSHSFTP *bool
|
EnableSSHSFTP *bool
|
||||||
EnableSSHLocalPortForwarding *bool
|
EnableSSHLocalPortForwarding *bool
|
||||||
@@ -181,6 +183,9 @@ type EngineServices struct {
|
|||||||
UpdateManager *updater.Manager
|
UpdateManager *updater.Manager
|
||||||
ClientMetrics *metrics.ClientMetrics
|
ClientMetrics *metrics.ClientMetrics
|
||||||
MetricsCtx context.Context
|
MetricsCtx context.Context
|
||||||
|
// NetMgr gates the reconnection loops on OS-reported network
|
||||||
|
// availability; nil disables gating.
|
||||||
|
NetMgr *netevents.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
||||||
@@ -204,6 +209,10 @@ type Engine struct {
|
|||||||
config *EngineConfig
|
config *EngineConfig
|
||||||
mobileDep MobileDependency
|
mobileDep MobileDependency
|
||||||
|
|
||||||
|
// netMgr gates the peer reconnection guards on OS-reported network
|
||||||
|
// availability; nil disables gating.
|
||||||
|
netMgr *netevents.Manager
|
||||||
|
|
||||||
// STUNs is a list of STUN servers used by ICE
|
// STUNs is a list of STUN servers used by ICE
|
||||||
STUNs []*stun.URI
|
STUNs []*stun.URI
|
||||||
// TURNs is a list of STUN servers used by ICE
|
// TURNs is a list of STUN servers used by ICE
|
||||||
@@ -337,6 +346,7 @@ func NewEngine(
|
|||||||
syncMsgMux: &sync.Mutex{},
|
syncMsgMux: &sync.Mutex{},
|
||||||
config: config,
|
config: config,
|
||||||
mobileDep: mobileDep,
|
mobileDep: mobileDep,
|
||||||
|
netMgr: services.NetMgr,
|
||||||
STUNs: []*stun.URI{},
|
STUNs: []*stun.URI{},
|
||||||
TURNs: []*stun.URI{},
|
TURNs: []*stun.URI{},
|
||||||
networkSerial: 0,
|
networkSerial: 0,
|
||||||
@@ -863,8 +873,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
|||||||
}
|
}
|
||||||
// third, add the peer connections again
|
// third, add the peer connections again
|
||||||
for _, p := range modified {
|
for _, p := range modified {
|
||||||
err := e.addNewPeer(p)
|
if err := e.addNewPeer(p); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1251,6 +1260,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
|||||||
e.config.EnableSSHLocalPortForwarding,
|
e.config.EnableSSHLocalPortForwarding,
|
||||||
e.config.EnableSSHRemotePortForwarding,
|
e.config.EnableSSHRemotePortForwarding,
|
||||||
e.config.DisableSSHAuth,
|
e.config.DisableSSHAuth,
|
||||||
|
&e.config.RemoteJobsAllowed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,6 +1346,13 @@ func (e *Engine) receiveJobEvents() {
|
|||||||
ID: msg.ID,
|
ID: msg.ID,
|
||||||
Status: mgmProto.JobStatus_failed,
|
Status: mgmProto.JobStatus_failed,
|
||||||
}
|
}
|
||||||
|
// Remote jobs are an explicit opt-in. When not enabled on this
|
||||||
|
// peer, every job is refused before any work is done.
|
||||||
|
if !e.config.RemoteJobsAllowed {
|
||||||
|
log.Warnf("refusing remote job: remote jobs are not enabled on this peer (enable with --allow-remote-jobs)")
|
||||||
|
resp.Reason = []byte("remote jobs are not enabled on this peer")
|
||||||
|
return &resp
|
||||||
|
}
|
||||||
switch params := msg.WorkloadParameters.(type) {
|
switch params := msg.WorkloadParameters.(type) {
|
||||||
case *mgmProto.JobRequest_Bundle:
|
case *mgmProto.JobRequest_Bundle:
|
||||||
bundleResult, err := e.handleBundle(params.Bundle)
|
bundleResult, err := e.handleBundle(params.Bundle)
|
||||||
@@ -1365,7 +1382,25 @@ func (e *Engine) receiveJobEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobResponse_Bundle, error) {
|
func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobResponse_Bundle, error) {
|
||||||
log.Infof("handle remote debug bundle request: %s", params.String())
|
// The upload URL can carry a host, credentials, or query tokens, so it is
|
||||||
|
// kept out of the info-level line; the full parameters stay available at
|
||||||
|
// debug level for troubleshooting.
|
||||||
|
log.Infof("handle remote debug bundle request: anonymize=%v anonymize_level=%q log_file_count=%d bundle_for=%v bundle_for_time=%d",
|
||||||
|
params.GetAnonymize(), params.GetAnonymizeLevel(), params.GetLogFileCount(), params.GetBundleFor(), params.GetBundleForTime())
|
||||||
|
log.Debugf("remote debug bundle request parameters: %s", params.String())
|
||||||
|
|
||||||
|
// Resolve the upload destination: an MDM override, when set, takes
|
||||||
|
// precedence over the management-supplied URL. Both are validated the same
|
||||||
|
// way; an empty result falls back to the default upload server downstream.
|
||||||
|
uploadURL := params.GetUploadUrl()
|
||||||
|
if override := e.config.ProfileConfig.DebugBundleUploadURL; override != "" {
|
||||||
|
log.Infof("using MDM debug bundle upload URL override instead of the management-supplied value")
|
||||||
|
uploadURL = override
|
||||||
|
}
|
||||||
|
if err := validateBundleUploadURL(uploadURL); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
syncResponse, err := e.GetLatestSyncResponse()
|
syncResponse, err := e.GetLatestSyncResponse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("get latest sync response: %v", err)
|
log.Warnf("get latest sync response: %v", err)
|
||||||
@@ -1393,7 +1428,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
|||||||
|
|
||||||
waitFor := time.Duration(params.BundleForTime) * time.Minute
|
waitFor := time.Duration(params.BundleForTime) * time.Minute
|
||||||
|
|
||||||
uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String())
|
uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String(), uploadURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1406,6 +1441,16 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateBundleUploadURL sanity-checks a management-supplied upload URL for a
|
||||||
|
// remote debug bundle job. It delegates to profilemanager.ValidateBundleUploadURL
|
||||||
|
// so the executor and the MDM policy override share one definition of the rule
|
||||||
|
// (empty accepted; otherwise a well-formed https URL with a host) and cannot
|
||||||
|
// drift. The host is deliberately left unconstrained pending a decision on
|
||||||
|
// management-directed uploads.
|
||||||
|
func validateBundleUploadURL(raw string) error {
|
||||||
|
return profilemanager.ValidateBundleUploadURL(raw)
|
||||||
|
}
|
||||||
|
|
||||||
// receiveManagementEvents connects to the Management Service event stream to receive updates from the management service
|
// receiveManagementEvents connects to the Management Service event stream to receive updates from the management service
|
||||||
// E.g. when a new peer has been registered and we are allowed to connect to it.
|
// E.g. when a new peer has been registered and we are allowed to connect to it.
|
||||||
func (e *Engine) receiveManagementEvents() {
|
func (e *Engine) receiveManagementEvents() {
|
||||||
@@ -1486,8 +1531,12 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, networkMap.GetPeerConfig().GetLazyConnectionEnabled()); err != nil {
|
// Only update the flag when the sync carries a peer config; a nil peer config
|
||||||
log.Errorf("failed to update lazy connection feature flag: %v", err)
|
// (e.g. a partial update) must not reset the cached flag to false.
|
||||||
|
if peerConfig := networkMap.GetPeerConfig(); peerConfig != nil {
|
||||||
|
if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, peerConfig.GetLazyConnectionEnabled()); err != nil {
|
||||||
|
log.Errorf("failed to update lazy connection feature flag: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if e.firewall != nil {
|
if e.firewall != nil {
|
||||||
@@ -1553,8 +1602,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
|||||||
|
|
||||||
// Ingress forward rules
|
// Ingress forward rules
|
||||||
done = e.phase("forward_rules")
|
done = e.phase("forward_rules")
|
||||||
forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules())
|
if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Errorf("failed to update forward rules, err: %v", err)
|
log.Errorf("failed to update forward rules, err: %v", err)
|
||||||
}
|
}
|
||||||
done()
|
done()
|
||||||
@@ -1572,8 +1620,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
|||||||
|
|
||||||
// must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
|
// must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
|
||||||
done = e.phase("lazy_exclude")
|
done = e.phase("lazy_exclude")
|
||||||
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
|
e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(remotePeers))
|
||||||
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
|
||||||
done()
|
done()
|
||||||
|
|
||||||
e.networkSerial = serial
|
e.networkSerial = serial
|
||||||
@@ -1817,15 +1864,15 @@ func addrToString(addr netip.Addr) string {
|
|||||||
// addNewPeers adds peers that were not know before but arrived from the Management service with the update
|
// addNewPeers adds peers that were not know before but arrived from the Management service with the update
|
||||||
func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
||||||
for _, p := range peersUpdate {
|
for _, p := range peersUpdate {
|
||||||
err := e.addNewPeer(p)
|
if err := e.addNewPeer(p); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addNewPeer add peer if connection doesn't exist
|
// addNewPeer add peer if connection doesn't exist. A peer that is not lazy by
|
||||||
|
// policy gets an always-active connection instead.
|
||||||
func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error {
|
func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error {
|
||||||
peerKey := peerConfig.GetWgPubKey()
|
peerKey := peerConfig.GetWgPubKey()
|
||||||
peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps()))
|
peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps()))
|
||||||
@@ -1860,7 +1907,8 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error {
|
|||||||
log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err)
|
log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn); exists {
|
permanent := !e.connMgr.PeerLazyDefault(peerConfig.GetLazyState())
|
||||||
|
if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, permanent); exists {
|
||||||
conn.Close(false)
|
conn.Close(false)
|
||||||
return fmt.Errorf("peer already exists: %s", peerKey)
|
return fmt.Errorf("peer already exists: %s", peerKey)
|
||||||
}
|
}
|
||||||
@@ -1894,6 +1942,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
|||||||
PermissiveMode: e.config.RosenpassPermissive,
|
PermissiveMode: e.config.RosenpassPermissive,
|
||||||
},
|
},
|
||||||
ICEConfig: e.createICEConfig(),
|
ICEConfig: e.createICEConfig(),
|
||||||
|
NetMgr: e.netMgr,
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceDependencies := peer.ServiceDependencies{
|
serviceDependencies := peer.ServiceDependencies{
|
||||||
@@ -2562,7 +2611,7 @@ func (e *Engine) SetCapture(pc device.PacketCapture) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afc := capture.NewAFPacketCapture(intf.Name(), sess)
|
afc := capture.NewAFPacketCapture(intf.Name(), sess)
|
||||||
if err := afc.Start(); err != nil {
|
if err := afc.Start(); err != nil { //nolint:staticcheck // always errors on non-Linux builds
|
||||||
return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err)
|
return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err)
|
||||||
}
|
}
|
||||||
e.afpacketCapture = afc
|
e.afpacketCapture = afc
|
||||||
@@ -2651,46 +2700,19 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal
|
|||||||
return forwardingRules, nberrors.FormatErrorOrNil(merr)
|
return forwardingRules, nberrors.FormatErrorOrNil(merr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool {
|
// toExcludedLazyPeers returns the peers that must have an always-active
|
||||||
|
// connection: those that are not lazy by policy (the per-peer lazy state or the
|
||||||
|
// account flag, subject to the local override).
|
||||||
|
func (e *Engine) toExcludedLazyPeers(peers []*mgmProto.RemotePeerConfig) map[string]bool {
|
||||||
excludedPeers := make(map[string]bool)
|
excludedPeers := make(map[string]bool)
|
||||||
|
for _, p := range peers {
|
||||||
// Ingress forward targets: inbound forwarded traffic is initiated remotely and
|
if !e.connMgr.PeerLazyDefault(p.GetLazyState()) {
|
||||||
// cannot wake a lazy connection, so the peer routing the target must stay
|
excludedPeers[p.GetWgPubKey()] = true
|
||||||
// permanently connected. AllowedIPs are already parsed on the peer conn, so
|
|
||||||
// reuse those typed prefixes instead of re-parsing the network map strings.
|
|
||||||
for _, r := range rules {
|
|
||||||
for _, p := range peers {
|
|
||||||
if e.peerRoutesAddr(p, r.TranslatedAddress) {
|
|
||||||
log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey())
|
|
||||||
excludedPeers[p.GetWgPubKey()] = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return excludedPeers
|
return excludedPeers
|
||||||
}
|
}
|
||||||
|
|
||||||
// peerRoutesAddr reports whether the peer is a router for addr, matched against
|
|
||||||
// the peer's already-parsed AllowedIPs from the store (the same typed value the
|
|
||||||
// lazy manager consumes) rather than re-parsing the network map strings.
|
|
||||||
func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool {
|
|
||||||
prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey())
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return prefixesContain(prefixes, addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// prefixesContain reports whether addr falls within any of the prefixes.
|
|
||||||
func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool {
|
|
||||||
for _, prefix := range prefixes {
|
|
||||||
if prefix.Contains(addr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isChecksEqual checks if two slices of checks are equal.
|
// isChecksEqual checks if two slices of checks are equal.
|
||||||
func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
|
func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
|
||||||
normalize := func(checks []*mgmProto.Checks) []string {
|
normalize := func(checks []*mgmProto.Checks) []string {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestValidateBundleUploadURL covers the sanity check applied to a
|
||||||
|
// management-supplied upload URL before a remote debug bundle is generated.
|
||||||
|
func TestValidateBundleUploadURL(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "empty falls back to default", raw: ""},
|
||||||
|
{name: "https with host", raw: "https://upload.debug.netbird.io/upload"},
|
||||||
|
{name: "https self-hosted host", raw: "https://upload.example.com"},
|
||||||
|
{name: "plaintext rejected", raw: "http://upload.example.com", wantErr: true},
|
||||||
|
{name: "missing host rejected", raw: "https:///upload", wantErr: true},
|
||||||
|
{name: "port-only authority rejected", raw: "https://:443", wantErr: true},
|
||||||
|
{name: "non-url scheme rejected", raw: "ftp://upload.example.com", wantErr: true},
|
||||||
|
{name: "garbage rejected", raw: "://not a url", wantErr: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateBundleUploadURL(tc.raw)
|
||||||
|
if tc.wantErr {
|
||||||
|
require.Error(t, err, "an invalid upload URL must be rejected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NoError(t, err, "a valid or empty upload URL must be accepted")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user