mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-05 14:31:29 +02:00
Merge remote-tracking branch 'origin/main' into windows-dns-firewall
# Conflicts: # client/internal/dns/host_windows.go
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 \
|
||||
&& apt-get -y install --no-install-recommends\
|
||||
|
||||
33
.github/workflows/buf.yml
vendored
Normal file
33
.github/workflows/buf.yml
vendored
Normal file
@@ -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
|
||||
11
.github/workflows/golang-test-linux.yml
vendored
11
.github/workflows/golang-test-linux.yml
vendored
@@ -233,7 +233,7 @@ jobs:
|
||||
-e GOCACHE=${CONTAINER_GOCACHE} \
|
||||
-e GOMODCACHE=${CONTAINER_GOMODCACHE} \
|
||||
-e CONTAINER=${CONTAINER} \
|
||||
golang:1.25-alpine \
|
||||
golang:1.26.7-alpine \
|
||||
sh -c ' \
|
||||
apk update; apk add --no-cache \
|
||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
||||
@@ -730,6 +730,11 @@ jobs:
|
||||
- name: Install modules
|
||||
run: go mod tidy
|
||||
|
||||
- name: Run Mage
|
||||
uses: magefile/mage-action@a662bd8c29d8106879588cfff83b2faf6e6f59db # v4.0.0
|
||||
with:
|
||||
install-only: true
|
||||
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
@@ -738,9 +743,7 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=integration -coverprofile=coverage.txt \
|
||||
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
|
||||
-timeout 20m ./management/server/http/...
|
||||
mage integrationtest:all -gotestflags="-coverprofile=coverage.txt"
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -215,7 +215,7 @@ jobs:
|
||||
echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV
|
||||
|
||||
- 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
|
||||
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
|
||||
@@ -435,7 +435,7 @@ jobs:
|
||||
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
|
||||
- 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
|
||||
# Version derived from go.mod so the binding generator always matches
|
||||
# the wails runtime the binary links against.
|
||||
|
||||
13
.github/workflows/sync-tag.yml
vendored
13
.github/workflows/sync-tag.yml
vendored
@@ -37,3 +37,16 @@ jobs:
|
||||
repo: netbirdio/ios-client
|
||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||
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
|
||||
- src: client/ui/build/appicon.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:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
@@ -116,6 +121,11 @@ nfpms:
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.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:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
|
||||
@@ -192,7 +192,7 @@ dependencies are installed. Here is a short guide on how that can be done.
|
||||
|
||||
### Requirements
|
||||
|
||||
#### Go 1.25
|
||||
#### Go 1.26
|
||||
|
||||
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:
|
||||
|
||||
- Go ≥ 1.25
|
||||
- Go ≥ 1.26
|
||||
- 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 `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest`
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<strong>
|
||||
Start using NetBird at <a href="https://netbird.io/pricing">netbird.io</a>
|
||||
<br/>
|
||||
See <a href="https://netbird.io/docs/">Documentation</a>
|
||||
See <a href="https://docs.netbird.io/">Documentation</a>
|
||||
<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>
|
||||
</strong>
|
||||
|
||||
@@ -96,6 +96,42 @@ components:
|
||||
— the management-side control plane: providers, policies, guardrails, limits, routing,
|
||||
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
|
||||
|
||||
Full documentation, architecture, and quickstart:
|
||||
|
||||
@@ -26,8 +26,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/netevents"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -82,13 +81,10 @@ type Client struct {
|
||||
deviceName string
|
||||
uiVersion string
|
||||
networkChangeListener listener.NetworkChangeListener
|
||||
// netState outlives engine restarts: it mirrors the OS connectivity, not
|
||||
// the engine lifecycle. Run and RunWithoutLogin inject it into each new
|
||||
// ConnectClient, which distributes it to every reconnection loop.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
|
||||
sweeper *netsweep.Sweeper
|
||||
// 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
|
||||
connectClient *internal.ConnectClient
|
||||
@@ -153,16 +149,16 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
|
||||
|
||||
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
|
||||
system.SetIFaceDiscover(iFaceDiscover)
|
||||
recorder := peer.NewRecorder("")
|
||||
return &Client{
|
||||
deviceName: deviceName,
|
||||
uiVersion: uiVersion,
|
||||
tunAdapter: tunAdapter,
|
||||
iFaceDiscover: iFaceDiscover,
|
||||
recorder: peer.NewRecorder(""),
|
||||
recorder: recorder,
|
||||
ctxCancelLock: &sync.Mutex{},
|
||||
networkChangeListener: networkChangeListener,
|
||||
netState: netstate.New(),
|
||||
sweeper: netsweep.New(),
|
||||
netMgr: netevents.NewManager(recorder),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,8 +199,9 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
|
||||
}
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
internal.WithNetEvents(c.netMgr))
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
// This path runs the interactive SSO flow, so reaching here means the peer
|
||||
// is authenticated again — release the latch Status() reports from. Clear
|
||||
@@ -246,7 +243,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
|
||||
// todo do not throw error in case of cancelled context
|
||||
ctx = internal.CtxInitState(ctx)
|
||||
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
|
||||
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
|
||||
internal.WithNetEvents(c.netMgr))
|
||||
c.setState(cfg, cacheDir, cfgFile, connectClient)
|
||||
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
||||
}
|
||||
@@ -298,9 +295,12 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
|
||||
// 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.netState.Set(available)
|
||||
c.recorder.SetNetworkAvailable(available)
|
||||
c.netMgr.SetNetworkAvailable(available)
|
||||
}
|
||||
|
||||
// NotifyNetworkChange marks the management, signal and relay connections
|
||||
@@ -308,8 +308,7 @@ func (c *Client) SetNetworkAvailable(available bool) {
|
||||
// whatever has not redialed on the new network by then. The engine and the
|
||||
// TUN device stay untouched.
|
||||
func (c *Client) NotifyNetworkChange() {
|
||||
c.sweeper.MarkNetworkChange()
|
||||
log.Infof("network change: connections marked stale")
|
||||
c.netMgr.NotifyNetworkChange()
|
||||
}
|
||||
|
||||
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mobile"
|
||||
"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
|
||||
// pointing at an account that cannot be used.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -208,7 +209,7 @@ func profileLoginHint(cfgPath string) string {
|
||||
if cfgPath == "" {
|
||||
return ""
|
||||
}
|
||||
return readProfileEmail(cfgPath)
|
||||
return mobile.ReadProfileEmail(cfgPath)
|
||||
}
|
||||
|
||||
// runOAuthFlow drives an already acquired OAuth flow to a token: requests the
|
||||
|
||||
@@ -3,42 +3,37 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mobile"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// Profile represents a profile for gomobile
|
||||
// Profile represents a profile for gomobile.
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login. Kept across logouts; cleared when the profile is
|
||||
// removed. See profile_state.go.
|
||||
// removed. See client/mobile/profile_state.go.
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
// ProfileArray wraps profiles for gomobile compatibility
|
||||
// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot
|
||||
// bind Go slices directly).
|
||||
type ProfileArray struct {
|
||||
items []*Profile
|
||||
}
|
||||
|
||||
// Length returns the number of profiles
|
||||
// Length returns the number of profiles.
|
||||
func (p *ProfileArray) Length() int {
|
||||
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 {
|
||||
if i < 0 || i >= len(p.items) {
|
||||
return nil
|
||||
@@ -46,259 +41,98 @@ func (p *ProfileArray) Get(i int) *Profile {
|
||||
return p.items[i]
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
/data/data/io.netbird.client/files/ ← configDir parameter
|
||||
├── 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
|
||||
// ProfileManager adapts the shared mobile profile manager (client/mobile) to
|
||||
// gomobile-friendly types. See that package for the on-disk layout and
|
||||
// semantics.
|
||||
type ProfileManager struct {
|
||||
configDir string
|
||||
serviceMgr *profilemanager.ServiceManager
|
||||
impl *mobile.ProfileManager
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Set the default config path for Android (stored in root configDir, not profiles/)
|
||||
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,
|
||||
}
|
||||
return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive)
|
||||
internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername)
|
||||
profiles, err := pm.impl.ListProfiles()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list profiles: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert internal profiles to Android Profile type
|
||||
var profiles []*Profile
|
||||
for _, p := range internalProfiles {
|
||||
profiles = append(profiles, &Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Email: pm.profileEmail(p.ID.String()),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
items := make([]*Profile, 0, len(profiles))
|
||||
for i := range profiles {
|
||||
items = append(items, fromMobileProfile(&profiles[i]))
|
||||
}
|
||||
|
||||
return &ProfileArray{items: profiles}, nil
|
||||
return &ProfileArray{items: items}, nil
|
||||
}
|
||||
|
||||
// GetActiveProfile returns the currently active profile name
|
||||
// GetActiveProfile returns the currently active profile.
|
||||
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
// Use ServiceManager to stay consistent with ListProfiles
|
||||
// ServiceManager uses active_profile.json
|
||||
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
||||
p, err := pm.impl.GetActiveProfile()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active profile: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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
|
||||
return fromMobileProfile(p), nil
|
||||
}
|
||||
|
||||
// profileEmail returns the account email recorded for a profile. Display-only, so
|
||||
// an unresolvable path degrades to "" rather than an error.
|
||||
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
|
||||
// SwitchProfile records the given profile ID as the active profile. The caller
|
||||
// must stop the VPN tunnel before switching.
|
||||
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
// Use ServiceManager to stay consistent with ListProfiles
|
||||
// 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
|
||||
return pm.impl.SwitchProfile(id)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Use ServiceManager (creates profile in profiles/ directory)
|
||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("created new profile: %s", profile.ID)
|
||||
return nil
|
||||
_, err := pm.impl.AddProfile(profileName)
|
||||
return err
|
||||
}
|
||||
|
||||
// LogoutProfile logs out from a profile (clears authentication)
|
||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
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)
|
||||
}
|
||||
|
||||
// The stored account email is kept on purpose, matching the desktop and CLI
|
||||
// logout semantics: the next login passes it as the login_hint so the IdP
|
||||
// preselects the account. Removing the profile is what deletes it.
|
||||
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/.
|
||||
// RenameProfile changes the display name of the profile identified by id. The
|
||||
// on-disk filename (the ID) is left unchanged.
|
||||
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil {
|
||||
return fmt.Errorf("failed to rename profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("renamed profile %s to: %s", id, newName)
|
||||
return nil
|
||||
return pm.impl.RenameProfile(id, newName)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use ServiceManager (removes profile from profiles/ directory)
|
||||
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
|
||||
return fmt.Errorf("failed to remove profile: %w", err)
|
||||
}
|
||||
|
||||
// The account file is this package's, not the ServiceManager's, so it must
|
||||
// go here. The default profile has a fixed filename, so a recreated one
|
||||
// would otherwise inherit the deleted profile's email as its login_hint.
|
||||
// Not fatal: the profile itself is gone.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to remove stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Infof("removed profile: %s", id)
|
||||
return nil
|
||||
return pm.impl.RemoveProfile(id)
|
||||
}
|
||||
|
||||
// getProfileConfigPath returns the config file path for a profile
|
||||
// This is needed for Android-specific path handling (netbird.cfg for default profile)
|
||||
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()
|
||||
// GetConfigPath returns the config file path for the given profile ID. Java
|
||||
// should call this instead of constructing paths with Preferences.configFile().
|
||||
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
|
||||
// Java should call this instead of constructing paths with Preferences.stateFile()
|
||||
// GetStateFilePath returns the state file path for the given profile ID. Java
|
||||
// should call this instead of constructing paths with Preferences.stateFile().
|
||||
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
||||
if id == "" || id == profilemanager.DefaultProfileName {
|
||||
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
|
||||
return pm.impl.GetStateFilePath(id)
|
||||
}
|
||||
|
||||
// GetActiveConfigPath returns the config file path for the currently active profile
|
||||
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile()
|
||||
// GetActiveConfigPath returns the config file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||
}
|
||||
return pm.GetConfigPath(activeProfile.ID)
|
||||
return pm.impl.GetActiveConfigPath()
|
||||
}
|
||||
|
||||
// GetActiveStateFilePath returns the state file path for the currently active profile
|
||||
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile()
|
||||
// GetActiveStateFilePath returns the state file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||
}
|
||||
return pm.GetStateFilePath(activeProfile.ID)
|
||||
return pm.impl.GetActiveStateFilePath()
|
||||
}
|
||||
|
||||
func fromMobileProfile(p *mobile.Profile) *Profile {
|
||||
return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,9 @@ func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) {
|
||||
if configDir == "" || profileID == "" {
|
||||
return nil, fmt.Errorf("profile prefs require a config dir and profile ID")
|
||||
}
|
||||
pm := NewProfileManager(configDir)
|
||||
prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(profileID), androidUsername)
|
||||
prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve profile prefs: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
return &profilePrefs{prefs: prefs}, nil
|
||||
}
|
||||
|
||||
125
client/android/split_tunnel.go
Normal file
125
client/android/split_tunnel.go
Normal file
@@ -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),
|
||||
}
|
||||
}
|
||||
34
client/android/split_tunnel_store.go
Normal file
34
client/android/split_tunnel_store.go
Normal file
@@ -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))
|
||||
}
|
||||
151
client/android/split_tunnel_test.go
Normal file
151
client/android/split_tunnel_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -114,7 +113,7 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("get active profile: %v", err)
|
||||
}
|
||||
currUser, err := user.Current()
|
||||
currUser, err := profilemanager.InvokingUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %v", err)
|
||||
}
|
||||
|
||||
13
client/cmd/jobs.go
Normal file
13
client/cmd/jobs.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -53,7 +52,7 @@ var loginCmd = &cobra.Command{
|
||||
// nolint
|
||||
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, hostName)
|
||||
}
|
||||
username, err := user.Current()
|
||||
username, err := profilemanager.InvokingUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %v", err)
|
||||
}
|
||||
@@ -74,7 +73,7 @@ var loginCmd = &cobra.Command{
|
||||
if providedSetupKey != "" {
|
||||
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 nil
|
||||
@@ -92,7 +91,7 @@ var loginCmd = &cobra.Command{
|
||||
return fmt.Errorf("daemon login failed: %v", err)
|
||||
}
|
||||
|
||||
cmd.Println("Logging successfully")
|
||||
cmd.Println("Login successful")
|
||||
|
||||
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
|
||||
// management server's ExtendAuthSession RPC. The tunnel stays up
|
||||
// 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)
|
||||
if err != nil {
|
||||
//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
|
||||
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
|
||||
// without a hint if the lookup fails.
|
||||
pm := profilemanager.NewProfileManager()
|
||||
if active, perr := pm.GetActiveProfile(); perr == nil {
|
||||
if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" {
|
||||
req.Hint = &profState.Email
|
||||
}
|
||||
if profState, perr := pm.GetProfileState(activeProf.ID); perr == nil && profState.Email != "" {
|
||||
req.Hint = &profState.Email
|
||||
}
|
||||
|
||||
startResp, err := client.RequestExtendAuthSession(ctx, req)
|
||||
@@ -235,9 +232,11 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
|
||||
// switch profile if provided
|
||||
|
||||
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 prof, nil
|
||||
}
|
||||
|
||||
activeProf, err := pm.GetActiveProfile()
|
||||
@@ -251,20 +250,19 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
|
||||
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)
|
||||
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 {
|
||||
return fmt.Errorf("switch profile: %v", err)
|
||||
return nil, fmt.Errorf("switch profile: %v", err)
|
||||
}
|
||||
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
log.Errorf("failed to connect to service CLI interface %v", err)
|
||||
return err
|
||||
return nil, fmt.Errorf("connect to service CLI interface: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
@@ -272,17 +270,17 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage
|
||||
|
||||
status, err := client.Status(ctx, &proto.StatusRequest{})
|
||||
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 _, err := client.Down(ctx, &proto.DownRequest{}); err != nil {
|
||||
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
|
||||
@@ -345,7 +343,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
|
||||
if err != nil {
|
||||
return fmt.Errorf("foreground login failed: %v", err)
|
||||
}
|
||||
cmd.Println("Logging successfully")
|
||||
cmd.Println("Login successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/user"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ var logoutCmd = &cobra.Command{
|
||||
if profileName != "" {
|
||||
req.ProfileName = &profileName
|
||||
|
||||
currUser, err := user.Current()
|
||||
currUser, err := profilemanager.InvokingUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/user"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
@@ -97,7 +96,7 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
currUser, err := user.Current()
|
||||
currUser, err := profilemanager.InvokingUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
@@ -138,7 +137,7 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
currUser, err := user.Current()
|
||||
currUser, err := profilemanager.InvokingUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
@@ -179,7 +178,7 @@ func renameProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
currUser, err := user.Current()
|
||||
currUser, err := profilemanager.InvokingUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
@@ -233,7 +232,7 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
currUser, err := user.Current()
|
||||
currUser, err := profilemanager.InvokingUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
@@ -261,7 +260,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
|
||||
profileManager := profilemanager.NewProfileManager()
|
||||
handle := args[0]
|
||||
|
||||
currUser, err := user.Current()
|
||||
currUser, err := profilemanager.InvokingUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
"github.com/netbirdio/netbird/client/internal/localmetrics"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,8 @@ const (
|
||||
dnsResolverAddress = "dns-resolver-address"
|
||||
enableRosenpassFlag = "enable-rosenpass"
|
||||
rosenpassPermissiveFlag = "rosenpass-permissive"
|
||||
enableLocalMetricsFlag = "enable-local-metrics"
|
||||
localMetricsAddressFlag = "local-metrics-address"
|
||||
preSharedKeyFlag = "preshared-key"
|
||||
interfaceNameFlag = "interface-name"
|
||||
wireguardPortFlag = "wireguard-port"
|
||||
@@ -80,6 +83,8 @@ var (
|
||||
updateSettingsDisabled bool
|
||||
captureEnabled bool
|
||||
networksDisabled bool
|
||||
localMetricsEnabled bool
|
||||
localMetricsAddr string
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
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(&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(&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().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" {
|
||||
log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
|
||||
"so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+
|
||||
"deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr)
|
||||
"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
|
||||
}
|
||||
|
||||
creds := ipcauth.NewTransportCredentials() //nolint:staticcheck
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
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)
|
||||
if err != nil {
|
||||
|
||||
230
client/cmd/up.go
230
client/cmd/up.go
@@ -2,10 +2,10 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -48,6 +48,8 @@ const (
|
||||
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 (
|
||||
foregroundMode bool
|
||||
dnsLabels []string
|
||||
@@ -122,23 +124,25 @@ func upFunc(cmd *cobra.Command, args []string) error {
|
||||
|
||||
pm := profilemanager.NewProfileManager()
|
||||
|
||||
username, err := user.Current()
|
||||
username, err := profilemanager.InvokingUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get current user: %v", err)
|
||||
}
|
||||
|
||||
var activeProf *profilemanager.Profile
|
||||
var profileSwitched bool
|
||||
// switch profile if provided
|
||||
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)
|
||||
}
|
||||
profileSwitched = true
|
||||
}
|
||||
|
||||
activeProf, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get active profile: %v", err)
|
||||
} else {
|
||||
activeProf, err = pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get active profile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if foregroundMode {
|
||||
@@ -150,13 +154,15 @@ func upFunc(cmd *cobra.Command, args []string) error {
|
||||
// switchOrCreateProfile switches the active profile to the one identified by
|
||||
// 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
|
||||
// missing profile instead of failing.
|
||||
func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error {
|
||||
// missing profile instead of failing. Returns the daemon-resolved profile so
|
||||
// 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)
|
||||
if err != nil {
|
||||
st, ok := gstatus.FromError(err)
|
||||
if !ok || st.Code() != codes.NotFound {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
// Don't fail immediately on a create error: a concurrent run may
|
||||
// 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)
|
||||
if resolvedID, err = switchProfile(ctx, handle, username); err != 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 {
|
||||
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
|
||||
@@ -302,6 +308,30 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
||||
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 !profileSwitched {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// setBoolPtrIfChanged points dst at a copy of val when the named bool flag was
|
||||
// explicitly set on cmd. It collapses the repeated
|
||||
// "if cmd.Flag(x).Changed { field = &val }" pattern in the request builders into
|
||||
// a single call, keeping their cognitive complexity within bounds.
|
||||
func setBoolPtrIfChanged(cmd *cobra.Command, name string, dst **bool, val bool) {
|
||||
if cmd.Flag(name).Changed {
|
||||
dst2 := val
|
||||
*dst = &dst2
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
req.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
@@ -440,6 +465,31 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
|
||||
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 err := parseInterfaceName(interfaceName); err != nil {
|
||||
log.Errorf("parse interface name: %v", err)
|
||||
@@ -499,6 +549,13 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
req.DisableIpv6 = &disableIPv6
|
||||
}
|
||||
|
||||
if cmd.Flag(enableLocalMetricsFlag).Changed {
|
||||
req.EnableLocalMetrics = &localMetricsEnabled
|
||||
}
|
||||
if cmd.Flag(localMetricsAddressFlag).Changed {
|
||||
req.LocalMetricsAddress = &localMetricsAddr
|
||||
}
|
||||
|
||||
return &req
|
||||
}
|
||||
|
||||
@@ -523,6 +580,7 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
ic.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &ic.RemoteJobsAllowed, remoteJobsAllowed)
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
ic.EnableSSHRoot = &enableSSHRoot
|
||||
@@ -616,9 +674,45 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
||||
ic.DisableIPv6 = &disableIPv6
|
||||
}
|
||||
|
||||
if cmd.Flag(enableLocalMetricsFlag).Changed {
|
||||
ic.LocalMetricsEnabled = &localMetricsEnabled
|
||||
}
|
||||
|
||||
if cmd.Flag(localMetricsAddressFlag).Changed {
|
||||
ic.LocalMetricsAddress = &localMetricsAddr
|
||||
}
|
||||
|
||||
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) {
|
||||
loginRequest := proto.LoginRequest{
|
||||
SetupKey: providedSetupKey,
|
||||
@@ -645,39 +739,21 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
loginRequest.RosenpassPermissive = &rosenpassPermissive
|
||||
}
|
||||
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
setSSHLoginFields(&loginRequest, cmd)
|
||||
setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &loginRequest.RemoteJobsAllowed, remoteJobsAllowed)
|
||||
|
||||
if cmd.Flag(disableAutoConnectFlag).Changed {
|
||||
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 err := parseInterfaceName(interfaceName); err != nil {
|
||||
return nil, err
|
||||
@@ -849,3 +925,31 @@ func isValidAddrPort(input string) bool {
|
||||
_, err := netip.ParseAddrPort(input)
|
||||
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
|
||||
}
|
||||
|
||||
88
client/cmd/up_test.go
Normal file
88
client/cmd/up_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -85,6 +85,11 @@ type Options struct {
|
||||
DisableIPv6 bool
|
||||
// BlockInbound blocks all inbound connections from peers
|
||||
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
|
||||
// LAN (RFC 1918, link-local, loopback) when it's used as a routing
|
||||
// peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
|
||||
@@ -210,6 +215,8 @@ func New(opts Options) (*Client, error) {
|
||||
DisableIPv6: &opts.DisableIPv6,
|
||||
BlockInbound: &opts.BlockInbound,
|
||||
BlockLANAccess: &opts.BlockLANAccess,
|
||||
RosenpassEnabled: &opts.EnableRosenpass,
|
||||
RosenpassPermissive: &opts.RosenpassPermissive,
|
||||
WireguardPort: opts.WireguardPort,
|
||||
MTU: opts.MTU,
|
||||
DNSLabels: parsedLabels,
|
||||
|
||||
@@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string {
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -763,7 +763,7 @@ func (r *router) addNatRule(pair firewall.RouterPair) error {
|
||||
exprs = append(exprs, sourceExp...)
|
||||
exprs = append(exprs, destExp...)
|
||||
|
||||
var markValue uint32 = nbnet.PreroutingFwmarkMasquerade
|
||||
markValue := nbnet.PreroutingFwmarkMasquerade
|
||||
if pair.Inverse {
|
||||
markValue = nbnet.PreroutingFwmarkMasqueradeReturn
|
||||
}
|
||||
|
||||
@@ -16,9 +16,14 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"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 {
|
||||
return grpc.WithContextDialer(dialContext)
|
||||
}
|
||||
@@ -26,7 +31,7 @@ func WithCustomDialer(_ bool, _ string) grpc.DialOption {
|
||||
// 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 *netsweep.Sweeper) grpc.DialOption {
|
||||
func WithSweeper(sweeper Sweeper) grpc.DialOption {
|
||||
return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
dial := sweeper.StartDial(ctx)
|
||||
defer dial.Release()
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/netevents/sweep"
|
||||
"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.
|
||||
// The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal").
|
||||
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
|
||||
@@ -14,6 +21,6 @@ func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
|
||||
}
|
||||
|
||||
// WithSweeper is a no-op on WASM/JS: there is no network change signal.
|
||||
func WithSweeper(_ *netsweep.Sweeper) grpc.DialOption {
|
||||
func WithSweeper(_ Sweeper) grpc.DialOption {
|
||||
return grpc.EmptyDialOption{}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,19 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
// 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 netState never fires, leaving plain backoff.Retry
|
||||
// the recovery. A nil watcher never fires, leaving plain backoff.Retry
|
||||
// behavior.
|
||||
func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, netState *netstate.State) error {
|
||||
func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, watcher ChangeWatcher) error {
|
||||
bo.Reset()
|
||||
for {
|
||||
err := operation()
|
||||
@@ -36,10 +39,14 @@ func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff,
|
||||
return err
|
||||
}
|
||||
|
||||
var changed <-chan struct{}
|
||||
if watcher != nil {
|
||||
changed = watcher.Changed()
|
||||
}
|
||||
timer := time.NewTimer(next)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-netState.Changed():
|
||||
case <-changed:
|
||||
timer.Stop()
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netevents/netstate"
|
||||
)
|
||||
|
||||
func TestRetryWakesOnNetworkChange(t *testing.T) {
|
||||
|
||||
@@ -57,10 +57,13 @@ type ICEBind struct {
|
||||
endpoints map[netip.Addr]net.Conn
|
||||
endpointsMu sync.Mutex
|
||||
recvChan chan recvMessage
|
||||
// every time when Close() is called (i.e. BindUpdate()) we need to close exit from the receiveRelayed and create a
|
||||
// new closed channel. With the closedChanMu we can safely close the channel and create a new one
|
||||
// Close() (i.e. BindUpdate()) closes closedChan to release receiveRelayed,
|
||||
// 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{}
|
||||
closedChanMu sync.RWMutex // protect the closeChan recreation from reading from it.
|
||||
closedChanMu sync.RWMutex
|
||||
closed bool
|
||||
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) {
|
||||
s.closed = false
|
||||
s.closedChanMu.Lock()
|
||||
s.closedChan = make(chan struct{})
|
||||
s.closedChanMu.Unlock()
|
||||
defer 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)
|
||||
if err != nil {
|
||||
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)
|
||||
return fns, port, nil
|
||||
}
|
||||
|
||||
func (s *ICEBind) Close() error {
|
||||
s.closedChanMu.Lock()
|
||||
defer s.closedChanMu.Unlock()
|
||||
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
|
||||
close(s.closedChan)
|
||||
|
||||
s.muUDPMux.Lock()
|
||||
@@ -121,6 +141,15 @@ func (s *ICEBind) Close() error {
|
||||
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 {
|
||||
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) {
|
||||
closedChan := b.currentClosedChan()
|
||||
|
||||
select {
|
||||
case <-b.closedChan:
|
||||
case <-closedChan:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
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
|
||||
// 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) {
|
||||
c.closedChanMu.RLock()
|
||||
defer c.closedChanMu.RUnlock()
|
||||
closedChan := c.currentClosedChan()
|
||||
|
||||
select {
|
||||
case <-c.closedChan:
|
||||
case <-closedChan:
|
||||
return 0, net.ErrClosed
|
||||
case msg, ok := <-c.recvChan:
|
||||
if !ok {
|
||||
|
||||
261
client/iface/bind/ice_bind_close_test.go
Normal file
261
client/iface/bind/ice_bind_close_test.go
Normal file
@@ -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 {
|
||||
if nbnet.AdvancedRouting() && runtime.GOOS == "linux" {
|
||||
return nbnet.ControlPlaneMark
|
||||
return int(nbnet.ControlPlaneMark)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Set the fwmark on the socket.
|
||||
err = nbnet.SetSocketOpt(fd)
|
||||
if err != nil {
|
||||
if closeErr := syscall.Close(fd); closeErr != nil {
|
||||
log.Warnf("failed to close raw socket fd: %v", closeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("setting fwmark failed: %w", err)
|
||||
}
|
||||
// The socket is bound to lo and only ever sends to the local WireGuard
|
||||
// instance, a destination the local routing table resolves without help, so
|
||||
// it carries no fwmark. Staying unmarked also keeps these packets out of
|
||||
// third-party NAT rules that match on marks: such a rule rewriting the
|
||||
// source would make WireGuard adopt the rewritten address as the peer
|
||||
// endpoint.
|
||||
|
||||
// Convert the file descriptor to a PacketConn.
|
||||
file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd))
|
||||
|
||||
77
client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go
Normal file
77
client/iface/wgproxy/rawsocket/rawsocket_privileged_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -368,6 +368,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
||||
a.config.EnableSSHLocalPortForwarding,
|
||||
a.config.EnableSSHRemotePortForwarding,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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))
|
||||
return tokenInfo, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
"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
|
||||
@@ -37,11 +39,13 @@ const (
|
||||
// The only exception is ActivatePeer, which is safe for concurrent use so the
|
||||
// DNS warm-up path can call it without contending on the engine mutex.
|
||||
type ConnMgr struct {
|
||||
peerStore *peerstore.Store
|
||||
statusRecorder *peer.Status
|
||||
iface lazyconn.WGIface
|
||||
force lazyForce
|
||||
rosenpassEnabled bool
|
||||
peerStore *peerstore.Store
|
||||
statusRecorder *peer.Status
|
||||
iface lazyconn.WGIface
|
||||
force lazyForce
|
||||
// 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
|
||||
// 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.
|
||||
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
|
||||
lazyCtx context.Context
|
||||
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 {
|
||||
e := &ConnMgr{
|
||||
peerStore: peerStore,
|
||||
statusRecorder: statusRecorder,
|
||||
iface: iface,
|
||||
force: resolveLazyForce(engineConfig.LazyConnection),
|
||||
rosenpassEnabled: engineConfig.RosenpassEnabled,
|
||||
peerStore: peerStore,
|
||||
statusRecorder: statusRecorder,
|
||||
iface: iface,
|
||||
force: resolveLazyForce(engineConfig.LazyConnection),
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Start initializes the connection manager. It starts the lazy connection manager when a
|
||||
// local override forces it on; with no local override it waits for the management feature flag.
|
||||
// Start initializes the connection manager. The lazy connection manager always runs so that
|
||||
// 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) {
|
||||
if e.lazyConnMgr != nil {
|
||||
log.Errorf("lazy connection manager is already started")
|
||||
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.statusRecorder.UpdateLazyConnection(true)
|
||||
e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault))
|
||||
}
|
||||
|
||||
// UpdatedRemoteFeatureFlag is called when the remote feature flag is updated.
|
||||
// If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again.
|
||||
// If disabled, then it closes the lazy connection manager and open the connections to all peers.
|
||||
func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error {
|
||||
// a local override (NB_LAZY_CONN or local config) takes precedence over management
|
||||
if e.force != lazyForceNone {
|
||||
return nil
|
||||
// UpdatedRemoteFeatureFlag caches the account-wide lazy feature flag. The manager itself is
|
||||
// not started or stopped here; the per-sync exclude-list reconciliation moves normal peers
|
||||
// between the lazy and always-active sets when the flag flips.
|
||||
func (e *ConnMgr) UpdatedRemoteFeatureFlag(_ context.Context, enabled bool) error {
|
||||
e.remoteLazyEnabled = enabled
|
||||
if e.isStartedWithLazyMgr() {
|
||||
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 {
|
||||
// if the lazy connection manager is already started, do not start it again
|
||||
if e.lazyConnMgr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if e.rosenpassEnabled {
|
||||
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
|
||||
switch state {
|
||||
case mgmProto.LazyState_LazyStateLazy:
|
||||
return true
|
||||
case mgmProto.LazyState_LazyStateEager:
|
||||
return false
|
||||
default:
|
||||
return e.remoteLazyEnabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +146,13 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) {
|
||||
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))
|
||||
|
||||
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 {
|
||||
return true
|
||||
}
|
||||
|
||||
if !e.isStartedWithLazyMgr() {
|
||||
if !e.isStartedWithLazyMgr() || permanent {
|
||||
if err := conn.Open(ctx); err != nil {
|
||||
conn.Log.Errorf("failed to open connection: %v", err)
|
||||
}
|
||||
@@ -296,6 +296,8 @@ func (e *ConnMgr) Close() {
|
||||
e.lazyConnMgrMu.Lock()
|
||||
e.lazyConnMgr = nil
|
||||
e.lazyConnMgrMu.Unlock()
|
||||
|
||||
e.appliedExcludeList = nil
|
||||
}
|
||||
|
||||
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.lazyConnMgrMu.Unlock()
|
||||
|
||||
e.appliedExcludeList = nil
|
||||
|
||||
e.wg.Add(1)
|
||||
go func() {
|
||||
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 {
|
||||
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/peerstore"
|
||||
"github.com/netbirdio/netbird/monotime"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
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 }
|
||||
|
||||
// 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,8 +38,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/internal/updater/installer"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netsweep"
|
||||
"github.com/netbirdio/netbird/client/netevents"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ssh"
|
||||
sshconfig "github.com/netbirdio/netbird/client/ssh/config"
|
||||
@@ -73,28 +72,17 @@ type ConnectClient struct {
|
||||
|
||||
persistSyncResponse bool
|
||||
|
||||
// netState gates every reconnection loop on OS-reported network
|
||||
// availability. Nil (the default) disables gating; mobile platforms
|
||||
// inject it via WithNetworkState.
|
||||
netState *netstate.State
|
||||
|
||||
// sweeper cuts the management, signal and relay connections on network
|
||||
// change; nil disables it.
|
||||
sweeper *netsweep.Sweeper
|
||||
// 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)
|
||||
|
||||
// WithNetworkState injects the OS network availability state that gates every
|
||||
// reconnection loop; without it gating is disabled.
|
||||
func WithNetworkState(netState *netstate.State) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.netState = netState }
|
||||
}
|
||||
|
||||
// WithSweeper injects the network change sweeper.
|
||||
func WithSweeper(sweeper *netsweep.Sweeper) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.sweeper = sweeper }
|
||||
// WithNetEvents injects the OS network event handling.
|
||||
func WithNetEvents(events *netevents.Manager) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.netMgr = events }
|
||||
}
|
||||
|
||||
func NewConnectClient(
|
||||
@@ -254,7 +242,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
wrapErr := state.Wrap
|
||||
myPrivateKey, err := wgtypes.ParseKey(c.config.PrivateKey)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -305,7 +293,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}
|
||||
|
||||
// suspend connection attempts while the OS reports no usable network
|
||||
if waited, err := c.netState.Wait(c.ctx); err != nil {
|
||||
if waited, err := c.netMgr.Wait(c.ctx); err != nil {
|
||||
return nil
|
||||
} else if waited {
|
||||
backOff.Reset()
|
||||
@@ -323,7 +311,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
|
||||
log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
|
||||
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled,
|
||||
mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper))
|
||||
mgm.WithNetEvents(c.netMgr))
|
||||
if err != nil {
|
||||
// On daemon shutdown / Down() the parent context is cancelled
|
||||
// and the dial fails with "context canceled". Wrapping that
|
||||
@@ -398,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
|
||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netState, c.sweeper)
|
||||
signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netMgr)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return wrapErr(err)
|
||||
@@ -435,7 +423,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}
|
||||
|
||||
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU,
|
||||
relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper))
|
||||
relayClient.WithNetEvents(c.netMgr))
|
||||
c.statusRecorder.SetRelayMgr(relayManager)
|
||||
if len(relayURLs) > 0 {
|
||||
if token != nil {
|
||||
@@ -463,7 +451,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
UpdateManager: c.updateManager,
|
||||
ClientMetrics: c.clientMetrics,
|
||||
MetricsCtx: c.ctx,
|
||||
NetState: c.netState,
|
||||
NetMgr: c.netMgr,
|
||||
}, mobileDependency)
|
||||
engine.SetSyncResponsePersistence(c.persistSyncResponse)
|
||||
c.engine = engine
|
||||
@@ -664,6 +652,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
RosenpassEnabled: config.RosenpassEnabled,
|
||||
RosenpassPermissive: config.RosenpassPermissive,
|
||||
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
||||
RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(config.RemoteJobsAllowed),
|
||||
EnableSSHRoot: config.EnableSSHRoot,
|
||||
EnableSSHSFTP: config.EnableSSHSFTP,
|
||||
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
||||
@@ -723,7 +712,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 {
|
||||
}
|
||||
|
||||
// connectToSignal creates Signal Service client and established a connection
|
||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) {
|
||||
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netMgr *netevents.Manager) (*signal.GrpcClient, error) {
|
||||
var sigTLSEnabled bool
|
||||
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
|
||||
sigTLSEnabled = true
|
||||
@@ -732,7 +721,7 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
|
||||
}
|
||||
|
||||
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled,
|
||||
signal.WithNetworkState(netState), signal.WithSweeper(sweeper))
|
||||
signal.WithNetEvents(netMgr))
|
||||
if err != nil {
|
||||
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)
|
||||
@@ -761,6 +750,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
||||
config.EnableSSHLocalPortForwarding,
|
||||
config.EnableSSHRemotePortForwarding,
|
||||
config.DisableSSHAuth,
|
||||
config.RemoteJobsAllowed,
|
||||
)
|
||||
return client.Login(sysInfo, pubSSHKey, config.DNSLabels)
|
||||
}
|
||||
|
||||
17
client/internal/daemonaddr/identity.go
Normal file
17
client/internal/daemonaddr/identity.go
Normal file
@@ -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)
|
||||
}
|
||||
29
client/internal/daemonaddr/identity_test.go
Normal file
29
client/internal/daemonaddr/identity_test.go
Normal file
@@ -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 {
|
||||
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 {
|
||||
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("BlockInbound: %v\n", g.internalConfig.BlockInbound))
|
||||
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))
|
||||
|
||||
if g.internalConfig.DisableNotifications != nil {
|
||||
|
||||
@@ -839,12 +839,13 @@ COMMIT`
|
||||
// the excluded set with a justification.
|
||||
func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
excluded := map[string]string{
|
||||
"PrivateKey": "sensitive: WireGuard private key",
|
||||
"PreSharedKey": "sensitive: WireGuard pre-shared key",
|
||||
"SSHKey": "sensitive: SSH private key",
|
||||
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
|
||||
"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",
|
||||
"PrivateKey": "sensitive: WireGuard private key",
|
||||
"PreSharedKey": "sensitive: WireGuard pre-shared key",
|
||||
"SSHKey": "sensitive: SSH private key",
|
||||
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
|
||||
"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",
|
||||
"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")
|
||||
@@ -864,6 +865,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
RosenpassEnabled: true,
|
||||
RosenpassPermissive: true,
|
||||
ServerSSHAllowed: &bTrue,
|
||||
RemoteJobsAllowed: &bTrue,
|
||||
EnableSSHRoot: &bTrue,
|
||||
EnableSSHSFTP: &bTrue,
|
||||
EnableSSHLocalPortForwarding: &bTrue,
|
||||
@@ -886,6 +888,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
ClientCertPath: "/tmp/cert",
|
||||
ClientCertKeyPath: "/tmp/key",
|
||||
LazyConnection: "on",
|
||||
DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret",
|
||||
MTU: 1280,
|
||||
DisableIPv6: true,
|
||||
SyncMessageVersion: func(v int) *int { return &v }(1),
|
||||
@@ -903,6 +906,13 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
g.addCommonConfigFields(&sb)
|
||||
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()
|
||||
typ := val.Type()
|
||||
var missing []string
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -35,10 +37,16 @@ var (
|
||||
// Registry locations of the host DNS configuration this package programs,
|
||||
// exported so a diagnostic reader reports the same locations that are written.
|
||||
const (
|
||||
// NRPTKeyPrefix starts the name of every NRPT rule key this client creates.
|
||||
// Older versions used different layouts under the same prefix: a single
|
||||
// unsuffixed key, then one key per domain, now one key per batch of domains.
|
||||
NRPTKeyPrefix = "NetBird-Match"
|
||||
// NRPTKeyPrefix starts the name of every NRPT rule key this client creates:
|
||||
// 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 = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
|
||||
@@ -54,8 +62,24 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix
|
||||
gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix
|
||||
dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + nrptMatchKeyName
|
||||
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"
|
||||
dnsPolicyConfigVersionValue = 2
|
||||
@@ -297,6 +321,13 @@ func (r *registryConfigurator) disableWINSForInterface() 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 err := r.applyRouteAll(config); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -314,8 +345,22 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager
|
||||
matchDomains = append(matchDomains, "."+strings.TrimSuffix(dConf.Domain, "."))
|
||||
}
|
||||
|
||||
if err := r.removeDNSMatchPolicies(); err != nil {
|
||||
log.Errorf("cleanup old dns match policies: %s", err)
|
||||
// The root namespace is a match domain like any other: it just happens to
|
||||
// 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 {
|
||||
@@ -422,6 +467,42 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
|
||||
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 {
|
||||
if err := removeRegistryKeyFromDNSPolicyConfig(policyPath); err != nil {
|
||||
return fmt.Errorf("remove existing dns policy: %w", err)
|
||||
@@ -441,7 +522,11 @@ func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []s
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -539,8 +624,11 @@ func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, 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 {
|
||||
log.Errorf("remove dns match policies: %s", err)
|
||||
return fmt.Errorf("remove dns match policies: %w", err)
|
||||
}
|
||||
|
||||
if err := r.deleteInterfaceRegistryKeyProperty(interfaceConfigSearchListKey); err != nil {
|
||||
@@ -627,9 +715,17 @@ func listNRPTRuleKeys(root string) ([]string, error) {
|
||||
|
||||
func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open HKEY_LOCAL_MACHINE\\%s: %v", regKeyPath, err)
|
||||
switch {
|
||||
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
|
||||
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)
|
||||
@@ -665,6 +761,20 @@ func refreshGroupPolicy() error {
|
||||
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) {
|
||||
if err := closer.Close(); err != nil {
|
||||
log.Errorf("failed to close: %s", err)
|
||||
|
||||
@@ -97,6 +97,145 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) {
|
||||
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) {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
|
||||
@@ -224,6 +224,7 @@ func TestResolver_StaleTriggersAsyncRefresh(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) {
|
||||
semaphore := make(chan struct{})
|
||||
r := NewResolver()
|
||||
chain := newFakeChain()
|
||||
chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2")
|
||||
@@ -239,7 +240,7 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond) // hold inflight long enough to collide
|
||||
<-semaphore // block the call to force request collision
|
||||
}
|
||||
|
||||
r.SetChainResolver(chain, 50)
|
||||
@@ -255,17 +256,17 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) {
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
wg.Go(func() {
|
||||
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()
|
||||
|
||||
waitFor(t, 2*time.Second, func() bool {
|
||||
return inflight.Load() == 0
|
||||
})
|
||||
assert.Eventually(t, func() bool { return inflight.Load() == 0 }, 2*time.Second, 100*time.Millisecond)
|
||||
|
||||
calls := chain.callCount("mgmt.example.com.", dns.TypeA)
|
||||
assert.LessOrEqual(t, calls, 2, "singleflight must collapse concurrent refreshes (got %d)", calls)
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestCreatePTRRecord_IPv4(t *testing.T) {
|
||||
@@ -136,3 +138,88 @@ func TestAddReverseZone_IPv6(t *testing.T) {
|
||||
assert.Len(t, reverseZone.Records, 1)
|
||||
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)
|
||||
}
|
||||
|
||||
74
client/internal/elevate/elevate.go
Normal file
74
client/internal/elevate/elevate.go
Normal file
@@ -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()
|
||||
}
|
||||
18
client/internal/elevate/output.go
Normal file
18
client/internal/elevate/output.go
Normal file
@@ -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
|
||||
}
|
||||
21
client/internal/elevate/output_test.go
Normal file
21
client/internal/elevate/output_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
359
client/internal/elevate/run_darwin.go
Normal file
359
client/internal/elevate/run_darwin.go
Normal file
@@ -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]
|
||||
}
|
||||
111
client/internal/elevate/run_darwin_test.go
Normal file
111
client/internal/elevate/run_darwin_test.go
Normal file
@@ -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")
|
||||
}
|
||||
117
client/internal/elevate/run_unix.go
Normal file
117
client/internal/elevate/run_unix.go
Normal file
@@ -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
|
||||
}
|
||||
110
client/internal/elevate/run_unix_test.go
Normal file
110
client/internal/elevate/run_unix_test.go
Normal file
@@ -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")
|
||||
}
|
||||
19
client/internal/elevate/run_unsupported.go
Normal file
19
client/internal/elevate/run_unsupported.go
Normal file
@@ -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
|
||||
}
|
||||
187
client/internal/elevate/run_windows.go
Normal file
187
client/internal/elevate/run_windows.go
Normal file
@@ -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
|
||||
}
|
||||
40
client/internal/elevate/trusted.go
Normal file
40
client/internal/elevate/trusted.go
Normal file
@@ -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
|
||||
}
|
||||
10
client/internal/elevate/trusted_group_darwin.go
Normal file
10
client/internal/elevate/trusted_group_darwin.go
Normal file
@@ -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}
|
||||
9
client/internal/elevate/trusted_group_unix.go
Normal file
9
client/internal/elevate/trusted_group_unix.go
Normal file
@@ -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}
|
||||
119
client/internal/elevate/trusted_unix.go
Normal file
119
client/internal/elevate/trusted_unix.go
Normal file
@@ -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 })
|
||||
}
|
||||
148
client/internal/elevate/trusted_unix_test.go
Normal file
148
client/internal/elevate/trusted_unix_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
215
client/internal/elevate/trusted_windows.go
Normal file
215
client/internal/elevate/trusted_windows.go
Normal file
@@ -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
|
||||
}
|
||||
126
client/internal/elevate/trusted_windows_test.go
Normal file
126
client/internal/elevate/trusted_windows_test.go
Normal file
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/syncstore"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/jobexec"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netevents"
|
||||
cProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
@@ -137,6 +137,7 @@ type EngineConfig struct {
|
||||
RosenpassPermissive bool
|
||||
|
||||
ServerSSHAllowed bool
|
||||
RemoteJobsAllowed bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -182,9 +183,9 @@ type EngineServices struct {
|
||||
UpdateManager *updater.Manager
|
||||
ClientMetrics *metrics.ClientMetrics
|
||||
MetricsCtx context.Context
|
||||
// NetState gates the reconnection loops on OS-reported network
|
||||
// NetMgr gates the reconnection loops on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
NetState *netstate.State
|
||||
NetMgr *netevents.Manager
|
||||
}
|
||||
|
||||
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
|
||||
@@ -208,9 +209,9 @@ type Engine struct {
|
||||
config *EngineConfig
|
||||
mobileDep MobileDependency
|
||||
|
||||
// netState gates the peer reconnection guards on OS-reported network
|
||||
// netMgr gates the peer reconnection guards on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
netState *netstate.State
|
||||
netMgr *netevents.Manager
|
||||
|
||||
// STUNs is a list of STUN servers used by ICE
|
||||
STUNs []*stun.URI
|
||||
@@ -345,7 +346,7 @@ func NewEngine(
|
||||
syncMsgMux: &sync.Mutex{},
|
||||
config: config,
|
||||
mobileDep: mobileDep,
|
||||
netState: services.NetState,
|
||||
netMgr: services.NetMgr,
|
||||
STUNs: []*stun.URI{},
|
||||
TURNs: []*stun.URI{},
|
||||
networkSerial: 0,
|
||||
@@ -872,8 +873,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
||||
}
|
||||
// third, add the peer connections again
|
||||
for _, p := range modified {
|
||||
err := e.addNewPeer(p)
|
||||
if err != nil {
|
||||
if err := e.addNewPeer(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1260,6 +1260,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
e.config.EnableSSHRemotePortForwarding,
|
||||
e.config.DisableSSHAuth,
|
||||
&e.config.RemoteJobsAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1345,6 +1346,13 @@ func (e *Engine) receiveJobEvents() {
|
||||
ID: msg.ID,
|
||||
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) {
|
||||
case *mgmProto.JobRequest_Bundle:
|
||||
bundleResult, err := e.handleBundle(params.Bundle)
|
||||
@@ -1374,7 +1382,25 @@ func (e *Engine) receiveJobEvents() {
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
log.Warnf("get latest sync response: %v", err)
|
||||
@@ -1402,7 +1428,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1415,6 +1441,16 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
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
|
||||
// E.g. when a new peer has been registered and we are allowed to connect to it.
|
||||
func (e *Engine) receiveManagementEvents() {
|
||||
@@ -1495,8 +1531,12 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, networkMap.GetPeerConfig().GetLazyConnectionEnabled()); err != nil {
|
||||
log.Errorf("failed to update lazy connection feature flag: %v", err)
|
||||
// Only update the flag when the sync carries a peer config; a nil peer config
|
||||
// (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 {
|
||||
@@ -1562,8 +1602,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
||||
|
||||
// Ingress forward rules
|
||||
done = e.phase("forward_rules")
|
||||
forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules())
|
||||
if err != nil {
|
||||
if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil {
|
||||
log.Errorf("failed to update forward rules, err: %v", err)
|
||||
}
|
||||
done()
|
||||
@@ -1581,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
|
||||
done = e.phase("lazy_exclude")
|
||||
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
|
||||
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
||||
e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(remotePeers))
|
||||
done()
|
||||
|
||||
e.networkSerial = serial
|
||||
@@ -1826,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
|
||||
func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
|
||||
for _, p := range peersUpdate {
|
||||
err := e.addNewPeer(p)
|
||||
if err != nil {
|
||||
if err := e.addNewPeer(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
peerKey := peerConfig.GetWgPubKey()
|
||||
peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps()))
|
||||
@@ -1869,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)
|
||||
}
|
||||
|
||||
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)
|
||||
return fmt.Errorf("peer already exists: %s", peerKey)
|
||||
}
|
||||
@@ -1902,8 +1941,8 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
||||
Addr: e.getRosenpassAddr(),
|
||||
PermissiveMode: e.config.RosenpassPermissive,
|
||||
},
|
||||
ICEConfig: e.createICEConfig(),
|
||||
NetworkState: e.netState,
|
||||
ICEConfig: e.createICEConfig(),
|
||||
NetMgr: e.netMgr,
|
||||
}
|
||||
|
||||
serviceDependencies := peer.ServiceDependencies{
|
||||
@@ -2661,46 +2700,19 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal
|
||||
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)
|
||||
|
||||
// Ingress forward targets: inbound forwarded traffic is initiated remotely and
|
||||
// cannot wake a lazy connection, so the peer routing the target must stay
|
||||
// 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
|
||||
}
|
||||
for _, p := range peers {
|
||||
if !e.connMgr.PeerLazyDefault(p.GetLazyState()) {
|
||||
excludedPeers[p.GetWgPubKey()] = true
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
|
||||
normalize := func(checks []*mgmProto.Checks) []string {
|
||||
|
||||
36
client/internal/engine_bundle_test.go
Normal file
36
client/internal/engine_bundle_test.go
Normal file
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestPrefixesContain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefixes []string
|
||||
addr string
|
||||
want bool
|
||||
}{
|
||||
{name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true},
|
||||
{name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true},
|
||||
{name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false},
|
||||
{name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false},
|
||||
{name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true},
|
||||
{name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prefixes := make([]netip.Prefix, 0, len(tt.prefixes))
|
||||
for _, p := range tt.prefixes {
|
||||
prefixes = append(prefixes, netip.MustParsePrefix(p))
|
||||
}
|
||||
require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target
|
||||
// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from
|
||||
// lazy connections, matched via the peer's already-parsed AllowedIPs.
|
||||
func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) {
|
||||
const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0="
|
||||
const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0="
|
||||
|
||||
store := peerstore.NewConnStore()
|
||||
store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32"))
|
||||
store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32"))
|
||||
|
||||
e := &Engine{peerStore: store}
|
||||
|
||||
peers := []*mgmProto.RemotePeerConfig{
|
||||
{WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}},
|
||||
{WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}},
|
||||
}
|
||||
rules := []firewallManager.ForwardRule{
|
||||
{TranslatedAddress: netip.MustParseAddr("100.110.8.145")},
|
||||
}
|
||||
|
||||
excluded := e.toExcludedLazyPeers(rules, peers)
|
||||
|
||||
require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections")
|
||||
require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded")
|
||||
require.Len(t, excluded, 1)
|
||||
}
|
||||
|
||||
func TestToExcludedLazyPeers_NoRules(t *testing.T) {
|
||||
e := &Engine{peerStore: peerstore.NewConnStore()}
|
||||
|
||||
peers := []*mgmProto.RemotePeerConfig{
|
||||
{WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}},
|
||||
}
|
||||
|
||||
require.Empty(t, e.toExcludedLazyPeers(nil, peers))
|
||||
}
|
||||
|
||||
func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn {
|
||||
t.Helper()
|
||||
conn, err := peer.NewConn(peer.ConnConfig{
|
||||
Key: key,
|
||||
WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}},
|
||||
}, peer.ServiceDependencies{})
|
||||
require.NoError(t, err)
|
||||
return conn
|
||||
}
|
||||
@@ -519,7 +519,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
|
||||
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
|
||||
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
|
||||
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
|
||||
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
@@ -279,7 +279,8 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
|
||||
}, MobileDependency{})
|
||||
|
||||
wgIface := &MockWGIface{
|
||||
NameFunc: func() string { return "utun102" },
|
||||
NameFunc: func() string { return "utun102" },
|
||||
IsUserspaceBindFunc: func() bool { return true },
|
||||
RemovePeerFunc: func(peerKey string) error {
|
||||
return nil
|
||||
},
|
||||
|
||||
36
client/internal/getent/cgo_unix.go
Normal file
36
client/internal/getent/cgo_unix.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build cgo && !osusergo && !windows
|
||||
|
||||
package getent
|
||||
|
||||
import "os/user"
|
||||
|
||||
// Built with cgo, os/user resolves through libc (getpwnam_r and friends),
|
||||
// which goes through the host's NSS stack natively. Whatever it fails to
|
||||
// find, the getent command would not find either, so there is nothing to
|
||||
// fall back to.
|
||||
|
||||
// LookupUser looks up a user by name.
|
||||
func LookupUser(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// LookupUserID looks up a user by UID.
|
||||
func LookupUserID(uid string) (*user.User, error) {
|
||||
return user.LookupId(uid)
|
||||
}
|
||||
|
||||
// CurrentUser returns the user this process runs as.
|
||||
func CurrentUser() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// LookupGroupID looks up a group by GID.
|
||||
func LookupGroupID(gid string) (*user.Group, error) {
|
||||
return user.LookupGroupId(gid)
|
||||
}
|
||||
|
||||
// GroupIDs returns the IDs of the groups the user is a member of; libc's
|
||||
// getgrouplist handles NSS groups natively.
|
||||
func GroupIDs(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
6
client/internal/getent/getent.go
Normal file
6
client/internal/getent/getent.go
Normal file
@@ -0,0 +1,6 @@
|
||||
// Package getent resolves users and groups through the host's NSS stack.
|
||||
// Built without cgo, os/user reads /etc/passwd and /etc/group alone and misses
|
||||
// anything LDAP, SSSD or winbind provide; the getent and id commands resolve
|
||||
// through NSS whatever the build. The lookups here try the standard library
|
||||
// first, which needs no subprocess, and fall back to those commands.
|
||||
package getent
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package getent
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
@@ -10,38 +10,48 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLookupWithGetent_CurrentUser(t *testing.T) {
|
||||
func TestLookupUser_CurrentUser(t *testing.T) {
|
||||
// The current user should always be resolvable on any platform
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := lookupWithGetent(current.Username)
|
||||
u, err := LookupUser(current.Username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Username, u.Username)
|
||||
assert.Equal(t, current.Uid, u.Uid)
|
||||
assert.Equal(t, current.Gid, u.Gid)
|
||||
}
|
||||
|
||||
func TestLookupWithGetent_NonexistentUser(t *testing.T) {
|
||||
_, err := lookupWithGetent("nonexistent_user_xyzzy_12345")
|
||||
func TestLookupUser_NonexistentUser(t *testing.T) {
|
||||
_, err := LookupUser("nonexistent_user_xyzzy_12345")
|
||||
require.Error(t, err, "should fail for nonexistent user")
|
||||
}
|
||||
|
||||
func TestCurrentUserWithGetent(t *testing.T) {
|
||||
func TestLookupUserID_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := LookupUserID(current.Uid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Username, u.Username)
|
||||
assert.Equal(t, current.Uid, u.Uid)
|
||||
}
|
||||
|
||||
func TestCurrentUser(t *testing.T) {
|
||||
stdUser, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := currentUserWithGetent()
|
||||
u, err := CurrentUser()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, stdUser.Uid, u.Uid)
|
||||
assert.Equal(t, stdUser.Username, u.Username)
|
||||
}
|
||||
|
||||
func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
|
||||
func TestGroupIDs_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := groupIdsWithFallback(current)
|
||||
groups, err := GroupIDs(current)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "current user should have at least one group")
|
||||
|
||||
@@ -53,32 +63,30 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShellFromGetent_CurrentUser(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows stub always returns empty, which is correct
|
||||
shell := getShellFromGetent("1000")
|
||||
assert.Empty(t, shell, "Windows stub should return empty")
|
||||
return
|
||||
}
|
||||
|
||||
func TestUserShell_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// getent may not be available on all systems (e.g., macOS without Homebrew getent)
|
||||
shell := getShellFromGetent(current.Uid)
|
||||
// getent may not be available on all systems (e.g., macOS without
|
||||
// Homebrew getent), and Windows has no login shells at all.
|
||||
shell, err := UserShell(current.Uid)
|
||||
if err != nil {
|
||||
t.Logf("UserShell failed, getent may not be available: %v", err)
|
||||
return
|
||||
}
|
||||
if shell == "" {
|
||||
t.Log("getShellFromGetent returned empty, getent may not be available")
|
||||
t.Log("UserShell returned empty, the user has no shell set")
|
||||
return
|
||||
}
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
|
||||
func TestLookupWithGetent_RootUser(t *testing.T) {
|
||||
func TestLookupUser_RootUser(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("no root user on Windows")
|
||||
}
|
||||
|
||||
u, err := lookupWithGetent("root")
|
||||
u, err := LookupUser("root")
|
||||
if err != nil {
|
||||
t.Skip("root user not available on this system")
|
||||
}
|
||||
@@ -86,25 +94,25 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIntegration_FullLookupChain exercises the complete user lookup chain
|
||||
// against the real system, testing that all wrappers (lookupWithGetent,
|
||||
// currentUserWithGetent, groupIdsWithFallback, getShellFromGetent) produce
|
||||
// consistent and correct results when composed together.
|
||||
// against the real system, testing that all wrappers (LookupUser,
|
||||
// CurrentUser, GroupIDs, UserShell) produce consistent and correct results
|
||||
// when composed together.
|
||||
func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
// Step 1: currentUserWithGetent must resolve the running user.
|
||||
current, err := currentUserWithGetent()
|
||||
require.NoError(t, err, "currentUserWithGetent must resolve the running user")
|
||||
// Step 1: CurrentUser must resolve the running user.
|
||||
current, err := CurrentUser()
|
||||
require.NoError(t, err, "CurrentUser must resolve the running user")
|
||||
require.NotEmpty(t, current.Uid)
|
||||
require.NotEmpty(t, current.Username)
|
||||
|
||||
// Step 2: lookupWithGetent by the same username must return matching identity.
|
||||
byName, err := lookupWithGetent(current.Username)
|
||||
// Step 2: LookupUser by the same username must return matching identity.
|
||||
byName, err := LookupUser(current.Username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID")
|
||||
assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID")
|
||||
assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home")
|
||||
|
||||
// Step 3: groupIdsWithFallback must return at least the primary GID.
|
||||
groups, err := groupIdsWithFallback(current)
|
||||
// Step 3: GroupIDs must return at least the primary GID.
|
||||
groups, err := GroupIDs(current)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "user must have at least one group")
|
||||
|
||||
@@ -119,29 +127,20 @@ func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid)
|
||||
|
||||
// Step 4: getShellFromGetent should either return a valid shell path or empty
|
||||
// (empty is OK when getent is not available, e.g. macOS without Homebrew getent).
|
||||
if runtime.GOOS != "windows" {
|
||||
shell := getShellFromGetent(current.Uid)
|
||||
if shell != "" {
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via
|
||||
// lookupWithGetent can have their groups resolved via groupIdsWithFallback,
|
||||
// testing the handoff between the two functions as used by the SSH server.
|
||||
// LookupUser can have their groups resolved via GroupIDs, testing the handoff
|
||||
// between the two functions as used by the SSH server.
|
||||
func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate the SSH server flow: lookup user, then get their groups.
|
||||
resolved, err := lookupWithGetent(current.Username)
|
||||
resolved, err := LookupUser(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := groupIdsWithFallback(resolved)
|
||||
groups, err := GroupIDs(resolved)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "resolved user must have groups")
|
||||
|
||||
@@ -154,19 +153,3 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_ShellLookupChain tests the full shell resolution chain
|
||||
// (getShellFromPasswd -> getShellFromGetent -> $SHELL -> default) on Unix.
|
||||
func TestIntegration_ShellLookupChain(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix shell lookup not applicable on Windows")
|
||||
}
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// getUserShell is the top-level function used by the SSH server.
|
||||
shell := getUserShell(current.Uid)
|
||||
require.NotEmpty(t, shell, "getUserShell must always return a shell")
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
110
client/internal/getent/nocgo_unix.go
Normal file
110
client/internal/getent/nocgo_unix.go
Normal file
@@ -0,0 +1,110 @@
|
||||
//go:build (!cgo || osusergo) && !windows
|
||||
|
||||
package getent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Without cgo, os/user only reads /etc/passwd and /etc/group and misses
|
||||
// NSS-provided users and groups; the getent and id commands go through the
|
||||
// host's NSS stack.
|
||||
|
||||
// LookupUser looks up a user by name, falling back to getent if os/user fails.
|
||||
func LookupUser(username string) (*user.User, error) {
|
||||
u, err := user.Lookup(username)
|
||||
if err == nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
stdErr := err
|
||||
log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err)
|
||||
|
||||
u, _, getentErr := passwdLookup(username)
|
||||
if getentErr != nil {
|
||||
log.Debugf("getent fallback for %q also failed: %v", username, getentErr)
|
||||
return nil, stdErr
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// LookupUserID looks up a user by UID, falling back to getent if os/user fails.
|
||||
func LookupUserID(uid string) (*user.User, error) {
|
||||
u, err := user.LookupId(uid)
|
||||
if err == nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
stdErr := err
|
||||
log.Debugf("os/user.LookupId(%q) failed, trying getent: %v", uid, err)
|
||||
|
||||
u, _, getentErr := passwdLookup(uid)
|
||||
if getentErr != nil {
|
||||
log.Debugf("getent fallback for uid %s also failed: %v", uid, getentErr)
|
||||
return nil, stdErr
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// CurrentUser returns the user this process runs as, falling back to getent
|
||||
// if os/user fails.
|
||||
func CurrentUser() (*user.User, error) {
|
||||
u, err := user.Current()
|
||||
if err == nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
stdErr := err
|
||||
uid := strconv.Itoa(os.Getuid())
|
||||
log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err)
|
||||
|
||||
u, _, getentErr := passwdLookup(uid)
|
||||
if getentErr != nil {
|
||||
return nil, stdErr
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// LookupGroupID looks up a group by GID, falling back to getent if os/user
|
||||
// fails.
|
||||
func LookupGroupID(gid string) (*user.Group, error) {
|
||||
g, err := user.LookupGroupId(gid)
|
||||
if err == nil {
|
||||
return g, nil
|
||||
}
|
||||
|
||||
stdErr := err
|
||||
log.Debugf("os/user.LookupGroupId(%q) failed, trying getent: %v", gid, err)
|
||||
|
||||
g, _, getentErr := groupLookup(gid)
|
||||
if getentErr != nil {
|
||||
log.Debugf("getent fallback for gid %s also failed: %v", gid, getentErr)
|
||||
return nil, stdErr
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// GroupIDs returns the IDs of the groups the user is a member of.
|
||||
// NOTE: unlike the lookups above, which try the standard library first, this
|
||||
// intentionally tries `id -G` first because without cgo, user.GroupIds only
|
||||
// reads /etc/group and silently returns incomplete results for NSS users
|
||||
// (no error, just missing groups). The id command goes through NSS and
|
||||
// returns the full set.
|
||||
func GroupIDs(u *user.User) ([]string, error) {
|
||||
ids, err := idGroups(u.Username)
|
||||
if err == nil {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err)
|
||||
|
||||
ids, stdErr := u.GroupIds()
|
||||
if stdErr != nil {
|
||||
return nil, stdErr
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
224
client/internal/getent/unix.go
Normal file
224
client/internal/getent/unix.go
Normal file
@@ -0,0 +1,224 @@
|
||||
//go:build !windows
|
||||
|
||||
package getent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const commandTimeout = 5 * time.Second
|
||||
|
||||
// groupFile lists which accounts are in which group, for hosts where the
|
||||
// getent command is not available (macOS ships without it).
|
||||
const groupFile = "/etc/group"
|
||||
|
||||
// UserShell returns the login shell getent reports for the user with this UID.
|
||||
// It reaches shells that /etc/passwd does not list, because getent resolves
|
||||
// through the host's NSS stack.
|
||||
func UserShell(uid string) (string, error) {
|
||||
_, shell, err := passwdLookup(uid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return shell, nil
|
||||
}
|
||||
|
||||
// GroupMembers returns the names of the group's members: from getent, which
|
||||
// resolves through NSS, or from /etc/group where getent is not available. A
|
||||
// group neither source describes is an error; an empty member list is not,
|
||||
// since accounts with the group as their primary one are not listed in it.
|
||||
func GroupMembers(name string) ([]string, error) {
|
||||
_, members, err := groupLookup(name)
|
||||
if err == nil {
|
||||
return members, nil
|
||||
}
|
||||
log.Debugf("getent cannot list group %q, reading %s: %v", name, groupFile, err)
|
||||
return groupMembersFromFile(groupFile, name)
|
||||
}
|
||||
|
||||
// passwdLookup executes `getent passwd <query>`, where query is a username or
|
||||
// UID, and returns the user and login shell.
|
||||
func passwdLookup(query string) (*user.User, string, error) {
|
||||
out, err := run("passwd", query)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return parsePasswd(string(out))
|
||||
}
|
||||
|
||||
// groupLookup executes `getent group <query>`, where query is a group name or
|
||||
// GID, and returns the group and its member names.
|
||||
func groupLookup(query string) (*user.Group, []string, error) {
|
||||
out, err := run("group", query)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return parseGroup(string(out))
|
||||
}
|
||||
|
||||
// run executes `getent <database> <key>` with a timeout.
|
||||
func run(database, key string) ([]byte, error) {
|
||||
if !validateInput(key) {
|
||||
return nil, fmt.Errorf("invalid getent input: %q", key)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "getent", database, key).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getent %s %s: %w", database, key, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parsePasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell"
|
||||
func parsePasswd(output string) (*user.User, string, error) {
|
||||
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
|
||||
if len(fields) < 6 {
|
||||
return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output)
|
||||
}
|
||||
|
||||
if fields[0] == "" || fields[2] == "" || fields[3] == "" {
|
||||
return nil, "", fmt.Errorf("missing required fields in getent output: %q", output)
|
||||
}
|
||||
|
||||
var shell string
|
||||
if len(fields) >= 7 {
|
||||
shell = fields[6]
|
||||
}
|
||||
|
||||
return &user.User{
|
||||
Username: fields[0],
|
||||
Uid: fields[2],
|
||||
Gid: fields[3],
|
||||
Name: fields[4],
|
||||
HomeDir: fields[5],
|
||||
}, shell, nil
|
||||
}
|
||||
|
||||
// parseGroup parses getent group output: "name:x:gid:member,member"
|
||||
func parseGroup(output string) (*user.Group, []string, error) {
|
||||
fields := strings.SplitN(strings.TrimSpace(output), ":", 4)
|
||||
if len(fields) < 3 {
|
||||
return nil, nil, fmt.Errorf("unexpected getent output (need 3+ fields): %q", output)
|
||||
}
|
||||
|
||||
if fields[0] == "" || fields[2] == "" {
|
||||
return nil, nil, fmt.Errorf("missing required fields in getent output: %q", output)
|
||||
}
|
||||
|
||||
var members []string
|
||||
if len(fields) >= 4 {
|
||||
members = splitMembers(fields[3])
|
||||
}
|
||||
return &user.Group{Name: fields[0], Gid: fields[2]}, members, nil
|
||||
}
|
||||
|
||||
func splitMembers(list string) []string {
|
||||
var members []string
|
||||
for member := range strings.SplitSeq(list, ",") {
|
||||
if member != "" {
|
||||
members = append(members, member)
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
// groupMembersFromFile finds the group's member list in a file of /etc/group's
|
||||
// format. A group the file does not describe, because it comes from LDAP or
|
||||
// another NSS source, is an error rather than an empty list.
|
||||
func groupMembersFromFile(path, name string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
log.Debugf("close %s: %v", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
// name:password:gid:member,member
|
||||
fields := strings.Split(scanner.Text(), ":")
|
||||
if len(fields) < 4 || fields[0] != name {
|
||||
continue
|
||||
}
|
||||
return splitMembers(fields[3]), nil
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
return nil, fmt.Errorf("%s does not describe group %q", path, name)
|
||||
}
|
||||
|
||||
// validateInput checks that the input is safe to pass to getent or id.
|
||||
// Allows POSIX usernames, numeric IDs, and common NSS extensions
|
||||
// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is
|
||||
// rejected so the input can never be parsed as a command-line flag.
|
||||
func validateInput(input string) bool {
|
||||
maxLen := 32
|
||||
if runtime.GOOS == "linux" {
|
||||
maxLen = 256
|
||||
}
|
||||
|
||||
if len(input) == 0 || len(input) > maxLen {
|
||||
return false
|
||||
}
|
||||
|
||||
if input[0] == '-' {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range input {
|
||||
if isAllowedChar(r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isAllowedChar(r rune) bool {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
|
||||
return true
|
||||
}
|
||||
switch r {
|
||||
case '.', '_', '-', '@', '+', '$':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// idGroups runs `id -G <username>` and returns the space-separated group IDs.
|
||||
func idGroups(username string) ([]string, error) {
|
||||
if !validateInput(username) {
|
||||
return nil, fmt.Errorf("invalid username for id command: %q", username)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "id", "-G", username).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("id -G %s: %w", username, err)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(string(out))
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("id -G %s: empty output", username)
|
||||
}
|
||||
return strings.Fields(trimmed), nil
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
package getent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -13,7 +15,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseGetentPasswd(t *testing.T) {
|
||||
func TestParsePasswd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
@@ -128,7 +130,7 @@ func TestParseGetentPasswd(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u, shell, err := parseGetentPasswd(tt.input)
|
||||
u, shell, err := parsePasswd(tt.input)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
@@ -147,7 +149,120 @@ func TestParseGetentPasswd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGetentInput(t *testing.T) {
|
||||
func TestParseGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantGroup *user.Group
|
||||
wantMembers []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "no members",
|
||||
input: "vma:x:1000:\n",
|
||||
wantGroup: &user.Group{Name: "vma", Gid: "1000"},
|
||||
},
|
||||
{
|
||||
name: "one member",
|
||||
input: "sudo:x:27:alice",
|
||||
wantGroup: &user.Group{Name: "sudo", Gid: "27"},
|
||||
wantMembers: []string{"alice"},
|
||||
},
|
||||
{
|
||||
name: "several members",
|
||||
input: "docker:x:998:alice,bob\n",
|
||||
wantGroup: &user.Group{Name: "docker", Gid: "998"},
|
||||
wantMembers: []string{"alice", "bob"},
|
||||
},
|
||||
{
|
||||
name: "too few fields",
|
||||
input: "bad:x",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty group name",
|
||||
input: ":x:1000:alice",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty GID",
|
||||
input: "vma:x::alice",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
g, members, err := parseGroup(tt.input)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantGroup.Name, g.Name, "group name")
|
||||
assert.Equal(t, tt.wantGroup.Gid, g.Gid, "GID")
|
||||
assert.Equal(t, tt.wantMembers, members, "members")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMembersFromFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry string
|
||||
want []string
|
||||
}{
|
||||
{name: "no members", entry: "vma:x:1000:"},
|
||||
{name: "only the owner", entry: "vma:x:1000:vma", want: []string{"vma"}},
|
||||
{name: "two members", entry: "vma:x:1000:vma,bob", want: []string{"vma", "bob"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "group")
|
||||
body := "root:x:0:\n" + tt.entry + "\nsudo:x:27:vma\n"
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0o644), "write the group file")
|
||||
|
||||
members, err := groupMembersFromFile(path, "vma")
|
||||
require.NoError(t, err, "entry %q", tt.entry)
|
||||
assert.Equal(t, tt.want, members, "entry %q", tt.entry)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A group the file does not describe, because it comes from LDAP or another
|
||||
// NSS source, is an error rather than an empty member list: the caller must
|
||||
// be able to tell "no members" from "no answer".
|
||||
func TestGroupMembersFromFileUnknownGroup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "group")
|
||||
require.NoError(t, os.WriteFile(path, []byte("root:x:0:\n"), 0o644), "write the group file")
|
||||
|
||||
_, err := groupMembersFromFile(path, "vma")
|
||||
assert.Error(t, err, "a group the file does not describe")
|
||||
|
||||
_, err = groupMembersFromFile(filepath.Join(t.TempDir(), "absent"), "vma")
|
||||
assert.Error(t, err, "no group file at all")
|
||||
}
|
||||
|
||||
// GroupMembers on the root group, which every Unix has, whichever source
|
||||
// answers for it.
|
||||
func TestGroupMembers_RootGroup(t *testing.T) {
|
||||
rootGroup := "root"
|
||||
switch runtime.GOOS {
|
||||
case "darwin", "dragonfly", "freebsd", "netbsd", "openbsd":
|
||||
rootGroup = "wheel"
|
||||
}
|
||||
|
||||
_, err := GroupMembers(rootGroup)
|
||||
assert.NoError(t, err, "the %s group must be describable", rootGroup)
|
||||
}
|
||||
|
||||
func TestValidateInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
@@ -180,7 +295,7 @@ func TestValidateGetentInput(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, validateGetentInput(tt.input))
|
||||
assert.Equal(t, tt.want, validateInput(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -193,12 +308,12 @@ func makeLongString(n int) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRunGetent_RootUser(t *testing.T) {
|
||||
func TestPasswdLookup_RootUser(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
u, shell, err := runGetent("root")
|
||||
u, shell, err := passwdLookup("root")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", u.Username)
|
||||
assert.Equal(t, "0", u.Uid)
|
||||
@@ -206,44 +321,55 @@ func TestRunGetent_RootUser(t *testing.T) {
|
||||
assert.NotEmpty(t, shell, "root should have a shell")
|
||||
}
|
||||
|
||||
func TestRunGetent_ByUID(t *testing.T) {
|
||||
func TestPasswdLookup_ByUID(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
u, _, err := runGetent("0")
|
||||
u, _, err := passwdLookup("0")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", u.Username)
|
||||
assert.Equal(t, "0", u.Uid)
|
||||
}
|
||||
|
||||
func TestRunGetent_NonexistentUser(t *testing.T) {
|
||||
func TestPasswdLookup_NonexistentUser(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
_, _, err := runGetent("nonexistent_user_xyzzy_12345")
|
||||
_, _, err := passwdLookup("nonexistent_user_xyzzy_12345")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunGetent_InvalidInput(t *testing.T) {
|
||||
_, _, err := runGetent("")
|
||||
func TestPasswdLookup_InvalidInput(t *testing.T) {
|
||||
_, _, err := passwdLookup("")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, _, err = runGetent("user\x00name")
|
||||
_, _, err = passwdLookup("user\x00name")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunGetent_NotAvailable(t *testing.T) {
|
||||
func TestPasswdLookup_NotAvailable(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err == nil {
|
||||
t.Skip("getent is available, can't test missing case")
|
||||
}
|
||||
|
||||
_, _, err := runGetent("root")
|
||||
_, _, err := passwdLookup("root")
|
||||
assert.Error(t, err, "should fail when getent is not installed")
|
||||
}
|
||||
|
||||
func TestRunIdGroups_CurrentUser(t *testing.T) {
|
||||
func TestGroupLookup_RootGroup(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
g, _, err := groupLookup("0")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0", g.Gid, "GID 0 resolves to the root group")
|
||||
assert.NotEmpty(t, g.Name, "the root group has a name")
|
||||
}
|
||||
|
||||
func TestIdGroups_CurrentUser(t *testing.T) {
|
||||
if _, err := exec.LookPath("id"); err != nil {
|
||||
t.Skip("id not available on this system")
|
||||
}
|
||||
@@ -251,7 +377,7 @@ func TestRunIdGroups_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := runIdGroups(current.Username)
|
||||
groups, err := idGroups(current.Username)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "current user should have at least one group")
|
||||
|
||||
@@ -261,20 +387,20 @@ func TestRunIdGroups_CurrentUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunIdGroups_NonexistentUser(t *testing.T) {
|
||||
func TestIdGroups_NonexistentUser(t *testing.T) {
|
||||
if _, err := exec.LookPath("id"); err != nil {
|
||||
t.Skip("id not available on this system")
|
||||
}
|
||||
|
||||
_, err := runIdGroups("nonexistent_user_xyzzy_12345")
|
||||
_, err := idGroups("nonexistent_user_xyzzy_12345")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunIdGroups_InvalidInput(t *testing.T) {
|
||||
_, err := runIdGroups("")
|
||||
func TestIdGroups_InvalidInput(t *testing.T) {
|
||||
_, err := idGroups("")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = runIdGroups("user\x00name")
|
||||
_, err = idGroups("user\x00name")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -286,7 +412,7 @@ func TestGetentResultsMatchStdlib(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
getentUser, _, err := runGetent(current.Username)
|
||||
getentUser, _, err := passwdLookup(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, current.Username, getentUser.Username, "username should match")
|
||||
@@ -303,7 +429,7 @@ func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
getentUser, _, err := runGetent(current.Uid)
|
||||
getentUser, _, err := passwdLookup(current.Uid)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID")
|
||||
@@ -323,12 +449,12 @@ func TestIdGroupsMatchStdlib(t *testing.T) {
|
||||
t.Skip("os/user.GroupIds() not working, likely CGO_ENABLED=0")
|
||||
}
|
||||
|
||||
idGroups, err := runIdGroups(current.Username)
|
||||
idGroupIDs, err := idGroups(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Deduplicate both lists: id -G can return duplicates (e.g., root in Docker)
|
||||
// and ElementsMatch treats duplicates as distinct.
|
||||
assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroups), "id -G should return same groups as os/user")
|
||||
assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroupIDs), "id -G should return same groups as os/user")
|
||||
}
|
||||
|
||||
func uniqueStrings(ss []string) []string {
|
||||
@@ -343,71 +469,3 @@ func uniqueStrings(ss []string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly
|
||||
// reads the current user's shell from /etc/passwd by comparing it against what
|
||||
// getent reports (which goes through NSS).
|
||||
func TestGetShellFromPasswd_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
shell := getShellFromPasswd(current.Uid)
|
||||
if shell == "" {
|
||||
t.Skip("current user not found in /etc/passwd (may be an NSS-only user)")
|
||||
}
|
||||
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
|
||||
if _, err := exec.LookPath("getent"); err == nil {
|
||||
_, getentShell, getentErr := runGetent(current.Uid)
|
||||
if getentErr == nil && getentShell != "" {
|
||||
assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read
|
||||
// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on
|
||||
// any standard Unix system.
|
||||
func TestGetShellFromPasswd_RootUser(t *testing.T) {
|
||||
shell := getShellFromPasswd("0")
|
||||
require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd")
|
||||
assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
|
||||
// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd
|
||||
// returns empty for a UID that doesn't exist in /etc/passwd.
|
||||
func TestGetShellFromPasswd_NonexistentUID(t *testing.T) {
|
||||
shell := getShellFromPasswd("4294967294")
|
||||
assert.Empty(t, shell, "nonexistent UID should return empty shell")
|
||||
}
|
||||
|
||||
// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly
|
||||
// and cross-validates every entry against getent to ensure parseGetentPasswd
|
||||
// and getShellFromPasswd agree on shell values.
|
||||
func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
|
||||
if _, err := exec.LookPath("getent"); err != nil {
|
||||
t.Skip("getent not available")
|
||||
}
|
||||
|
||||
// Pick a few well-known system UIDs that are virtually always in /etc/passwd.
|
||||
uids := []string{"0"} // root
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
uids = append(uids, current.Uid)
|
||||
|
||||
for _, uid := range uids {
|
||||
passwdShell := getShellFromPasswd(uid)
|
||||
if passwdShell == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, getentShell, err := runGetent(uid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid)
|
||||
}
|
||||
}
|
||||
36
client/internal/getent/windows.go
Normal file
36
client/internal/getent/windows.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build windows
|
||||
|
||||
package getent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/user"
|
||||
)
|
||||
|
||||
// Windows does not use NSS or getent; os/user resolves accounts there
|
||||
// without cgo, so everything delegates to it.
|
||||
|
||||
// LookupUser looks up a user by name.
|
||||
func LookupUser(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// LookupUserID looks up a user by UID.
|
||||
func LookupUserID(uid string) (*user.User, error) {
|
||||
return user.LookupId(uid)
|
||||
}
|
||||
|
||||
// CurrentUser returns the user this process runs as.
|
||||
func CurrentUser() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// GroupIDs returns the IDs of the groups the user is a member of.
|
||||
func GroupIDs(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
|
||||
// UserShell is unanswerable on Windows, which has no login-shell database.
|
||||
func UserShell(string) (string, error) {
|
||||
return "", errors.ErrUnsupported
|
||||
}
|
||||
@@ -91,6 +91,19 @@ func (i Identity) IsPrivileged() bool {
|
||||
return slices.Contains(i.Groups, sidAdministrators)
|
||||
}
|
||||
|
||||
// SameUser reports whether two identities are the same local principal. Only
|
||||
// the account is compared: the group set and the elevation flag describe what a
|
||||
// token may do, not who it belongs to. A SID on either side decides the
|
||||
// comparison, so a Windows principal never matches a Unix one on the UID both
|
||||
// happen to leave at zero. The zero Identity carries uid 0, so callers must
|
||||
// establish that both identities are real before the answer means anything.
|
||||
func (i Identity) SameUser(other Identity) bool {
|
||||
if i.SID != "" || other.SID != "" {
|
||||
return i.SID == other.SID
|
||||
}
|
||||
return i.UID == other.UID
|
||||
}
|
||||
|
||||
// String renders the identity for audit logs and denial messages.
|
||||
func (i Identity) String() string {
|
||||
if i.IsWindows() {
|
||||
|
||||
66
client/internal/ipcauth/identity_sameuser_test.go
Normal file
66
client/internal/ipcauth/identity_sameuser_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIdentitySameUser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a Identity
|
||||
b Identity
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "same uid",
|
||||
a: Identity{UID: 1000, GID: 1000},
|
||||
b: Identity{UID: 1000, GID: 1000},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same uid, different gid and pid still the same user",
|
||||
a: Identity{UID: 1000, GID: 1000, PID: 11},
|
||||
b: Identity{UID: 1000, GID: 27, PID: 22},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different uid",
|
||||
a: Identity{UID: 1000},
|
||||
b: Identity{UID: 1001},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "same sid",
|
||||
a: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
b: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same sid, elevation and groups differ",
|
||||
a: Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true, Groups: []string{sidAdministrators}},
|
||||
b: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different sid",
|
||||
a: Identity{SID: "S-1-5-21-1-2-3-1001"},
|
||||
b: Identity{SID: "S-1-5-21-1-2-3-1002"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "a windows principal is never a unix one",
|
||||
a: Identity{SID: "S-1-5-18"},
|
||||
b: Identity{UID: 0},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, tt.a.SameUser(tt.b))
|
||||
assert.Equal(t, tt.want, tt.b.SameUser(tt.a), "SameUser must be symmetric")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,12 @@ func SelfDelegatesTo() (Identity, bool) {
|
||||
return selfIdentity, true
|
||||
}
|
||||
|
||||
// The values PrivilegedActorKey returns.
|
||||
const (
|
||||
ActorKeyAdministrator = "administrator"
|
||||
ActorKeyRoot = "root"
|
||||
)
|
||||
|
||||
// PrivilegedActor names the principal a privileged operation requires, for use
|
||||
// in messages shown to the user.
|
||||
func PrivilegedActor() string {
|
||||
@@ -100,6 +106,16 @@ func PrivilegedActor() string {
|
||||
return "root"
|
||||
}
|
||||
|
||||
// PrivilegedActorKey identifies that principal without wording it, for a client
|
||||
// that writes its own message in the user's language. The words PrivilegedActor
|
||||
// returns are English, and a translated sentence cannot borrow them.
|
||||
func PrivilegedActorKey() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return ActorKeyAdministrator
|
||||
}
|
||||
return ActorKeyRoot
|
||||
}
|
||||
|
||||
// ElevatedCommand renders a command so that running it grants the privileges the
|
||||
// operation needs. Windows has no in-line equivalent of sudo, so the command is
|
||||
// returned unchanged and the user is expected to run it from an elevated
|
||||
|
||||
274
client/internal/localmetrics/localmetrics.go
Normal file
274
client/internal/localmetrics/localmetrics.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// Package localmetrics exposes client connection state as a local
|
||||
// Prometheus /metrics endpoint.
|
||||
package localmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
// DefaultListenAddress is used when local metrics are enabled without an explicit address.
|
||||
const DefaultListenAddress = "127.0.0.1:9191"
|
||||
|
||||
const (
|
||||
shutdownTimeout = 3 * time.Second
|
||||
readHeaderTimeout = 5 * time.Second
|
||||
readTimeout = 10 * time.Second
|
||||
writeTimeout = 30 * time.Second
|
||||
idleTimeout = time.Minute
|
||||
)
|
||||
|
||||
// statusSource provides the connection state snapshots the collector reads on scrape.
|
||||
type statusSource interface {
|
||||
GetPeerStates() []peer.State
|
||||
GetManagementState() peer.ManagementState
|
||||
GetSignalState() peer.SignalState
|
||||
}
|
||||
|
||||
// GathererProvider returns the current client metrics gatherer, or nil when
|
||||
// no engine is running. It is called on every scrape.
|
||||
type GathererProvider func() prometheus.Gatherer
|
||||
|
||||
// Manager runs the local /metrics HTTP endpoint according to the active
|
||||
// client configuration. Reconcile is safe to call on every config change.
|
||||
type Manager struct {
|
||||
status statusSource
|
||||
clientMetrics GathererProvider
|
||||
|
||||
mu sync.Mutex
|
||||
srv *http.Server
|
||||
addr string
|
||||
}
|
||||
|
||||
// NewManager creates a manager that serves metrics from status and
|
||||
// clientMetrics and shuts down when ctx is canceled.
|
||||
func NewManager(ctx context.Context, status statusSource, clientMetrics GathererProvider) *Manager {
|
||||
m := &Manager{status: status, clientMetrics: clientMetrics}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
m.Stop()
|
||||
}()
|
||||
return m
|
||||
}
|
||||
|
||||
// Reconcile starts, stops, or restarts the metrics endpoint to match the
|
||||
// desired state. An empty addr falls back to DefaultListenAddress.
|
||||
func (m *Manager) Reconcile(enabled bool, addr string) {
|
||||
if addr == "" {
|
||||
addr = DefaultListenAddress
|
||||
}
|
||||
warnIfNotLoopback(addr)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !enabled {
|
||||
m.stop()
|
||||
return
|
||||
}
|
||||
if m.srv != nil && m.addr == addr {
|
||||
return
|
||||
}
|
||||
m.stop()
|
||||
|
||||
registry := prometheus.NewRegistry()
|
||||
registry.MustRegister(newCollector(m.status))
|
||||
|
||||
gatherers := prometheus.Gatherers{registry, prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) {
|
||||
if m.clientMetrics == nil {
|
||||
return nil, nil
|
||||
}
|
||||
g := m.clientMetrics()
|
||||
if g == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return g.Gather()
|
||||
})}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{}))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
}
|
||||
m.srv = srv
|
||||
m.addr = addr
|
||||
|
||||
log.Infof("serving local metrics on http://%s/metrics", addr)
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Errorf("failed to serve local metrics on %s: %v", addr, err)
|
||||
m.clear(srv)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// clear drops the reference to srv so a later Reconcile with the same
|
||||
// address restarts it. A newer server may already have replaced it, in
|
||||
// which case the reference must stay.
|
||||
func (m *Manager) clear(srv *http.Server) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.srv != srv {
|
||||
return
|
||||
}
|
||||
m.srv = nil
|
||||
m.addr = ""
|
||||
}
|
||||
|
||||
// Stop shuts down the metrics endpoint if it is running.
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.stop()
|
||||
}
|
||||
|
||||
// stop shuts down the running server. Callers must hold m.mu.
|
||||
func (m *Manager) stop() {
|
||||
if m.srv == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
if err := m.srv.Shutdown(ctx); err != nil {
|
||||
log.Debugf("failed to shut down local metrics server: %v", err)
|
||||
}
|
||||
m.srv = nil
|
||||
m.addr = ""
|
||||
}
|
||||
|
||||
// collector converts status recorder snapshots into Prometheus metrics at scrape time.
|
||||
type collector struct {
|
||||
status statusSource
|
||||
|
||||
managementConnected *prometheus.Desc
|
||||
signalConnected *prometheus.Desc
|
||||
peersTotal *prometheus.Desc
|
||||
peersConnected *prometheus.Desc
|
||||
peerLatency *prometheus.Desc
|
||||
}
|
||||
|
||||
func newCollector(status statusSource) *collector {
|
||||
return &collector{
|
||||
status: status,
|
||||
managementConnected: prometheus.NewDesc(
|
||||
"netbird_management_connected",
|
||||
"Whether the client is connected to the management service (1 connected, 0 disconnected).",
|
||||
nil, nil,
|
||||
),
|
||||
signalConnected: prometheus.NewDesc(
|
||||
"netbird_signal_connected",
|
||||
"Whether the client is connected to the signal service (1 connected, 0 disconnected).",
|
||||
nil, nil,
|
||||
),
|
||||
peersTotal: prometheus.NewDesc(
|
||||
"netbird_peers",
|
||||
"Number of peers known to this client.",
|
||||
nil, nil,
|
||||
),
|
||||
peersConnected: prometheus.NewDesc(
|
||||
"netbird_peers_connected",
|
||||
"Number of connected peers by connection type.",
|
||||
[]string{"connection_type"}, nil,
|
||||
),
|
||||
peerLatency: prometheus.NewDesc(
|
||||
"netbird_peer_latency_seconds",
|
||||
"Round-trip latency per directly connected peer; relayed connections have no latency measurement.",
|
||||
[]string{"peer"}, nil,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Describe implements prometheus.Collector.
|
||||
func (c *collector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- c.managementConnected
|
||||
ch <- c.signalConnected
|
||||
ch <- c.peersTotal
|
||||
ch <- c.peersConnected
|
||||
ch <- c.peerLatency
|
||||
}
|
||||
|
||||
// Collect implements prometheus.Collector.
|
||||
func (c *collector) Collect(ch chan<- prometheus.Metric) {
|
||||
ch <- prometheus.MustNewConstMetric(c.managementConnected, prometheus.GaugeValue, boolToFloat(c.status.GetManagementState().Connected))
|
||||
ch <- prometheus.MustNewConstMetric(c.signalConnected, prometheus.GaugeValue, boolToFloat(c.status.GetSignalState().Connected))
|
||||
|
||||
peers := c.status.GetPeerStates()
|
||||
ch <- prometheus.MustNewConstMetric(c.peersTotal, prometheus.GaugeValue, float64(len(peers)))
|
||||
|
||||
var p2p, relayed float64
|
||||
for _, p := range peers {
|
||||
if p.ConnStatus != peer.StatusConnected {
|
||||
continue
|
||||
}
|
||||
if p.Relayed {
|
||||
relayed++
|
||||
continue
|
||||
}
|
||||
p2p++
|
||||
|
||||
if latency := p.Latency.Seconds(); latency > 0 {
|
||||
ch <- prometheus.MustNewConstMetric(c.peerLatency, prometheus.GaugeValue, latency, p.FQDN)
|
||||
}
|
||||
}
|
||||
ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, p2p, "p2p")
|
||||
ch <- prometheus.MustNewConstMetric(c.peersConnected, prometheus.GaugeValue, relayed, "relay")
|
||||
}
|
||||
|
||||
func boolToFloat(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsLoopback reports whether addr binds the endpoint to the local host only.
|
||||
// An empty address means DefaultListenAddress. It fails closed: an address
|
||||
// that cannot be confirmed loopback, including an unparseable one, is not.
|
||||
func IsLoopback(addr string) bool {
|
||||
if addr == "" {
|
||||
addr = DefaultListenAddress
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
ip, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return ip.Unmap().IsLoopback()
|
||||
}
|
||||
|
||||
// warnIfNotLoopback logs a warning when the listen address cannot be
|
||||
// confirmed to be local-only, since the endpoint exposes peer and
|
||||
// connectivity details without authentication.
|
||||
func warnIfNotLoopback(addr string) {
|
||||
if IsLoopback(addr) {
|
||||
return
|
||||
}
|
||||
log.Warnf("local metrics endpoint listens on non-loopback address %s and is reachable from the network without authentication", addr)
|
||||
}
|
||||
151
client/internal/localmetrics/localmetrics_test.go
Normal file
151
client/internal/localmetrics/localmetrics_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package localmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
)
|
||||
|
||||
type stubStatus struct {
|
||||
peers []peer.State
|
||||
management peer.ManagementState
|
||||
signal peer.SignalState
|
||||
}
|
||||
|
||||
func (s *stubStatus) GetPeerStates() []peer.State { return s.peers }
|
||||
func (s *stubStatus) GetManagementState() peer.ManagementState { return s.management }
|
||||
func (s *stubStatus) GetSignalState() peer.SignalState { return s.signal }
|
||||
|
||||
func testStatus() *stubStatus {
|
||||
return &stubStatus{
|
||||
management: peer.ManagementState{Connected: true},
|
||||
signal: peer.SignalState{Connected: true},
|
||||
peers: []peer.State{
|
||||
{FQDN: "peer-a.netbird.cloud", IP: "100.90.0.1", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 12 * time.Millisecond},
|
||||
{FQDN: "peer-b.netbird.cloud", IP: "100.90.0.2", ConnStatus: peer.StatusConnected, Relayed: false, Latency: 36 * time.Millisecond},
|
||||
{FQDN: "peer-c.netbird.cloud", IP: "100.90.0.3", ConnStatus: peer.StatusConnected, Relayed: true},
|
||||
{FQDN: "peer-d.netbird.cloud", IP: "100.90.0.4", ConnStatus: peer.StatusIdle},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollector(t *testing.T) {
|
||||
c := newCollector(testStatus())
|
||||
|
||||
expected := `
|
||||
# HELP netbird_management_connected Whether the client is connected to the management service (1 connected, 0 disconnected).
|
||||
# TYPE netbird_management_connected gauge
|
||||
netbird_management_connected 1
|
||||
# HELP netbird_peer_latency_seconds Round-trip latency per directly connected peer; relayed connections have no latency measurement.
|
||||
# TYPE netbird_peer_latency_seconds gauge
|
||||
netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012
|
||||
netbird_peer_latency_seconds{peer="peer-b.netbird.cloud"} 0.036
|
||||
# HELP netbird_peers Number of peers known to this client.
|
||||
# TYPE netbird_peers gauge
|
||||
netbird_peers 4
|
||||
# HELP netbird_peers_connected Number of connected peers by connection type.
|
||||
# TYPE netbird_peers_connected gauge
|
||||
netbird_peers_connected{connection_type="p2p"} 2
|
||||
netbird_peers_connected{connection_type="relay"} 1
|
||||
# HELP netbird_signal_connected Whether the client is connected to the signal service (1 connected, 0 disconnected).
|
||||
# TYPE netbird_signal_connected gauge
|
||||
netbird_signal_connected 1
|
||||
`
|
||||
require.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(expected)))
|
||||
}
|
||||
|
||||
func TestServe(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "must find a free port")
|
||||
addr := ln.Addr().String()
|
||||
require.NoError(t, ln.Close())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
m := NewManager(ctx, testStatus(), nil)
|
||||
m.Reconcile(true, addr)
|
||||
|
||||
var body string
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
body = string(data)
|
||||
return true
|
||||
}, 2*time.Second, 50*time.Millisecond, "metrics endpoint should come up")
|
||||
|
||||
assert.Contains(t, body, "netbird_peers 4")
|
||||
assert.Contains(t, body, `netbird_peers_connected{connection_type="relay"} 1`)
|
||||
assert.Contains(t, body, `netbird_peer_latency_seconds{peer="peer-a.netbird.cloud"} 0.012`)
|
||||
}
|
||||
|
||||
// A server that never came up must not be remembered, otherwise reconciling the
|
||||
// same address again is a no-op and the endpoint never recovers.
|
||||
func TestReconcileForgetsAFailedServer(t *testing.T) {
|
||||
blocker, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "must find a free port")
|
||||
t.Cleanup(func() { _ = blocker.Close() })
|
||||
addr := blocker.Addr().String()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
m := NewManager(ctx, testStatus(), nil)
|
||||
m.Reconcile(true, addr)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.srv == nil && m.addr == ""
|
||||
}, 2*time.Second, 20*time.Millisecond, "the failed server should be dropped")
|
||||
|
||||
require.NoError(t, blocker.Close())
|
||||
m.Reconcile(true, addr)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/metrics", addr))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}, 2*time.Second, 50*time.Millisecond, "reconciling the same address should retry the bind")
|
||||
}
|
||||
|
||||
func TestIsLoopback(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
"": true,
|
||||
"127.0.0.1:9191": true,
|
||||
"127.9.9.9:9191": true,
|
||||
"[::1]:9191": true,
|
||||
"[::ffff:127.0.0.1]:9191": true,
|
||||
"localhost:9191": true,
|
||||
"0.0.0.0:9191": false,
|
||||
"[::]:9191": false,
|
||||
"192.168.1.10:9191": false,
|
||||
"not-an-address": false,
|
||||
"example.com:9191": false,
|
||||
}
|
||||
|
||||
for addr, want := range tests {
|
||||
t.Run(addr, func(t *testing.T) {
|
||||
assert.Equal(t, want, IsLoopback(addr), "loopback verdict for %q", addr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,30 +45,13 @@ func (m *influxDBMetrics) RecordConnectionStages(
|
||||
isReconnection bool,
|
||||
timestamps ConnectionStageTimestamps,
|
||||
) {
|
||||
var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64
|
||||
|
||||
if !timestamps.SignalingReceived.IsZero() && !timestamps.ConnectionReady.IsZero() {
|
||||
signalingReceivedToConnection = timestamps.ConnectionReady.Sub(timestamps.SignalingReceived).Seconds()
|
||||
}
|
||||
|
||||
if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
|
||||
connectionToWgHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds()
|
||||
}
|
||||
|
||||
if !timestamps.SignalingReceived.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
|
||||
totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.SignalingReceived).Seconds()
|
||||
}
|
||||
|
||||
attemptType := "initial"
|
||||
if isReconnection {
|
||||
attemptType = "reconnection"
|
||||
}
|
||||
signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations()
|
||||
|
||||
connTypeStr := connectionType.String()
|
||||
tags := fmt.Sprintf("deployment_type=%s,connection_type=%s,attempt_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,connection_pair_id=%s",
|
||||
agentInfo.DeploymentType.String(),
|
||||
connTypeStr,
|
||||
attemptType,
|
||||
attemptType(isReconnection),
|
||||
agentInfo.Version,
|
||||
agentInfo.OS,
|
||||
agentInfo.Arch,
|
||||
@@ -94,7 +77,7 @@ func (m *influxDBMetrics) RecordConnectionStages(
|
||||
m.trimLocked()
|
||||
|
||||
log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs",
|
||||
agentInfo.DeploymentType.String(), connTypeStr, attemptType, signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
|
||||
agentInfo.DeploymentType.String(), connTypeStr, attemptType(isReconnection), signalingReceivedToConnection, connectionToWgHandshake, totalDuration)
|
||||
}
|
||||
|
||||
func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) {
|
||||
|
||||
@@ -32,13 +32,24 @@ Clients do not talk to InfluxDB directly. An ingest server sits between clients
|
||||
```text
|
||||
Client ──POST──▶ Ingest Server (:8087) ──▶ InfluxDB (internal)
|
||||
│
|
||||
├─ Checks the X-Peer-ID header format
|
||||
├─ Validates line protocol
|
||||
├─ Allowlists measurements, fields, and tags
|
||||
├─ Rejects out-of-bound values
|
||||
└─ Serves remote config at /config
|
||||
```
|
||||
|
||||
- **No secret/token-based client auth** — the ingest server holds the InfluxDB token server-side. Clients must send a hashed peer ID via `X-Peer-ID` header.
|
||||
- **Intentionally unauthenticated** — the endpoint receives obfuscated telemetry from
|
||||
the peers of both cloud and self-hosted deployments. For a self-hosted peer there is
|
||||
no shared trust anchor with this server, so there is nothing to authenticate against.
|
||||
- **`X-Peer-ID` is a correlation tag, not a credential** — it carries the obfuscated
|
||||
peer identifier so samples from one peer can be grouped. The server only checks that
|
||||
the header is well-formed (16 hex chars); a malformed value is rejected with
|
||||
`400 Bad Request`, not `401`. Any well-formed value is accepted by design, and the
|
||||
header must not be relied on for access control. The header itself is not forwarded
|
||||
to InfluxDB — the stored `peer_id` tag comes from the request body and is constrained
|
||||
only by the tag allowlist and the maximum tag value length.
|
||||
- **The InfluxDB token stays server-side** — clients never hold a write credential.
|
||||
- **InfluxDB is not exposed** — only accessible within the docker network
|
||||
- Source: `ingest/main.go`
|
||||
|
||||
@@ -61,7 +72,7 @@ Tags:
|
||||
- `version`: NetBird version string
|
||||
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
|
||||
- `arch`: CPU architecture (amd64, arm64, etc.)
|
||||
- `peer_id`: anonymised peer identifier (truncated SHA-256 of the WireGuard public key)
|
||||
- `peer_id`: obfuscated peer identifier (truncated SHA-256 of the WireGuard public key)
|
||||
- `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides
|
||||
|
||||
**Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online.
|
||||
@@ -195,7 +206,7 @@ docker compose up -d
|
||||
```
|
||||
|
||||
This starts:
|
||||
- **Ingest server** on http://localhost:8087 — accepts client metrics (requires `X-Peer-ID` header, no secret/token auth)
|
||||
- **Ingest server** on http://localhost:8087 — accepts client metrics (unauthenticated by design; expects a well-formed `X-Peer-ID` correlation tag)
|
||||
- **InfluxDB** — internal only, not exposed to host
|
||||
- **Grafana** on http://localhost:3001
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ const (
|
||||
maxDurationSeconds = 86400.0 // reject any duration field > 24 hours
|
||||
peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars
|
||||
maxTagValueLength = 64 // reject tag values longer than this
|
||||
readTimeout = 30 * time.Second // must fit reading a compressed body up to maxBodySize
|
||||
writeTimeout = 60 * time.Second // must exceed the upstream client timeout below
|
||||
idleTimeout = 120 * time.Second
|
||||
readHeaderTimeout = 10 * time.Second
|
||||
maxHeaderBytes = 1 << 20 // 1 MB
|
||||
)
|
||||
|
||||
type measurementSpec struct {
|
||||
@@ -124,8 +129,17 @@ func main() {
|
||||
fmt.Fprint(w, "ok") //nolint:errcheck
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: listenAddr,
|
||||
ReadTimeout: readTimeout,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
}
|
||||
|
||||
log.Printf("ingest server listening on %s, forwarding to %s", listenAddr, influxURL)
|
||||
if err := http.ListenAndServe(listenAddr, nil); err != nil { //nolint:gosec
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -137,8 +151,8 @@ func handleIngest(client *http.Client, influxURL, influxToken string) http.Handl
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateAuth(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
if err := validatePeerIDFormat(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,8 +201,13 @@ func forwardToInflux(w http.ResponseWriter, r *http.Request, client *http.Client
|
||||
io.Copy(w, resp.Body) //nolint:errcheck
|
||||
}
|
||||
|
||||
// validateAuth checks that the X-Peer-ID header contains a valid hashed peer ID.
|
||||
func validateAuth(r *http.Request) error {
|
||||
// validatePeerIDFormat checks the shape of the X-Peer-ID header. The header is a
|
||||
// correlation tag, not a credential: this endpoint is intentionally
|
||||
// unauthenticated so that peers of self-hosted deployments, for which no shared
|
||||
// trust anchor exists, can report obfuscated telemetry. The header is not forwarded
|
||||
// to InfluxDB, so this check does not bound the stored peer_id tag; it only rejects
|
||||
// a malformed header as a bad request rather than an auth failure.
|
||||
func validatePeerIDFormat(r *http.Request) error {
|
||||
peerID := r.Header.Get("X-Peer-ID")
|
||||
if peerID == "" {
|
||||
return fmt.Errorf("missing X-Peer-ID header")
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestValidateLineProtocol_RejectsOnBadLine(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidateAuth(t *testing.T) {
|
||||
func TestValidatePeerIDFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
peerID string
|
||||
@@ -113,7 +113,7 @@ func TestValidateAuth(t *testing.T) {
|
||||
if tt.peerID != "" {
|
||||
r.Header.Set("X-Peer-ID", tt.peerID)
|
||||
}
|
||||
err := validateAuth(r)
|
||||
err := validatePeerIDFormat(r)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
|
||||
@@ -89,6 +89,21 @@ type ConnectionStageTimestamps struct {
|
||||
WgHandshakeSuccess time.Time
|
||||
}
|
||||
|
||||
// Durations returns the stage durations in seconds. A duration is zero when
|
||||
// either of its timestamps is missing.
|
||||
func (c ConnectionStageTimestamps) Durations() (signalingToConnection, connectionToWgHandshake, total float64) {
|
||||
if !c.SignalingReceived.IsZero() && !c.ConnectionReady.IsZero() {
|
||||
signalingToConnection = c.ConnectionReady.Sub(c.SignalingReceived).Seconds()
|
||||
}
|
||||
if !c.ConnectionReady.IsZero() && !c.WgHandshakeSuccess.IsZero() {
|
||||
connectionToWgHandshake = c.WgHandshakeSuccess.Sub(c.ConnectionReady).Seconds()
|
||||
}
|
||||
if !c.SignalingReceived.IsZero() && !c.WgHandshakeSuccess.IsZero() {
|
||||
total = c.WgHandshakeSuccess.Sub(c.SignalingReceived).Seconds()
|
||||
}
|
||||
return signalingToConnection, connectionToWgHandshake, total
|
||||
}
|
||||
|
||||
// String returns a human-readable representation of the connection stage timestamps
|
||||
func (c ConnectionStageTimestamps) String() string {
|
||||
return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}",
|
||||
@@ -279,3 +294,11 @@ func (c *ClientMetrics) stopPushLocked() {
|
||||
c.wg.Wait()
|
||||
c.push.Store(nil)
|
||||
}
|
||||
|
||||
// attemptType returns the metric label for an initial vs reconnection attempt.
|
||||
func attemptType(isReconnection bool) string {
|
||||
if isReconnection {
|
||||
return "reconnection"
|
||||
}
|
||||
return "initial"
|
||||
}
|
||||
|
||||
@@ -2,10 +2,24 @@
|
||||
|
||||
package metrics
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
// NewClientMetrics creates a new ClientMetrics instance
|
||||
func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics {
|
||||
return &ClientMetrics{
|
||||
impl: newInfluxDBMetrics(),
|
||||
impl: newPrometheusMetrics(newInfluxDBMetrics()),
|
||||
agentInfo: agentInfo,
|
||||
}
|
||||
}
|
||||
|
||||
// PrometheusGatherer returns the registry with the mirrored Prometheus
|
||||
// metrics, or nil when unavailable.
|
||||
func (c *ClientMetrics) PrometheusGatherer() prometheus.Gatherer {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if pm, ok := c.impl.(*prometheusMetrics); ok {
|
||||
return pm.Gatherer()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
119
client/internal/metrics/prometheus.go
Normal file
119
client/internal/metrics/prometheus.go
Normal file
@@ -0,0 +1,119 @@
|
||||
//go:build !js
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// prometheusMetrics mirrors recorded client metrics into a Prometheus
|
||||
// registry for the local /metrics endpoint, then delegates to the wrapped
|
||||
// implementation. Export and Reset pass through untouched: Prometheus
|
||||
// metrics are cumulative and pull-based.
|
||||
type prometheusMetrics struct {
|
||||
next metricsImplementation
|
||||
registry *prometheus.Registry
|
||||
|
||||
connectionStages *prometheus.HistogramVec
|
||||
syncDuration prometheus.Histogram
|
||||
syncPhaseDuration *prometheus.HistogramVec
|
||||
loginDuration *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
func newPrometheusMetrics(next metricsImplementation) *prometheusMetrics {
|
||||
connectionBuckets := []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60}
|
||||
|
||||
m := &prometheusMetrics{
|
||||
next: next,
|
||||
registry: prometheus.NewRegistry(),
|
||||
connectionStages: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "netbird_peer_connection_stage_duration_seconds",
|
||||
Help: "Duration of peer connection establishment stages.",
|
||||
Buckets: connectionBuckets,
|
||||
}, []string{"stage", "connection_type", "attempt_type"}),
|
||||
syncDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "netbird_sync_duration_seconds",
|
||||
Help: "Duration of management sync message processing.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}),
|
||||
syncPhaseDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "netbird_sync_phase_duration_seconds",
|
||||
Help: "Duration of individual sync processing phases.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"phase"}),
|
||||
loginDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "netbird_login_duration_seconds",
|
||||
Help: "Duration of logins to the management service.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"success"}),
|
||||
}
|
||||
|
||||
m.registry.MustRegister(m.connectionStages, m.syncDuration, m.syncPhaseDuration, m.loginDuration)
|
||||
return m
|
||||
}
|
||||
|
||||
// Gatherer returns the registry holding the mirrored metrics.
|
||||
func (m *prometheusMetrics) Gatherer() prometheus.Gatherer {
|
||||
return m.registry
|
||||
}
|
||||
|
||||
// RecordConnectionStages implements metricsImplementation.
|
||||
func (m *prometheusMetrics) RecordConnectionStages(
|
||||
ctx context.Context,
|
||||
agentInfo AgentInfo,
|
||||
connectionPairID string,
|
||||
connectionType ConnectionType,
|
||||
isReconnection bool,
|
||||
timestamps ConnectionStageTimestamps,
|
||||
) {
|
||||
attempt := attemptType(isReconnection)
|
||||
connType := connectionType.String()
|
||||
|
||||
signalingToConnection, connectionToWgHandshake, total := timestamps.Durations()
|
||||
if signalingToConnection > 0 {
|
||||
m.connectionStages.WithLabelValues("signaling_to_connection", connType, attempt).Observe(signalingToConnection)
|
||||
}
|
||||
if connectionToWgHandshake > 0 {
|
||||
m.connectionStages.WithLabelValues("connection_to_wg_handshake", connType, attempt).Observe(connectionToWgHandshake)
|
||||
}
|
||||
if total > 0 {
|
||||
m.connectionStages.WithLabelValues("total", connType, attempt).Observe(total)
|
||||
}
|
||||
|
||||
m.next.RecordConnectionStages(ctx, agentInfo, connectionPairID, connectionType, isReconnection, timestamps)
|
||||
}
|
||||
|
||||
// RecordSyncDuration implements metricsImplementation.
|
||||
func (m *prometheusMetrics) RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) {
|
||||
m.syncDuration.Observe(duration.Seconds())
|
||||
m.next.RecordSyncDuration(ctx, agentInfo, duration)
|
||||
}
|
||||
|
||||
// RecordSyncPhase implements metricsImplementation.
|
||||
func (m *prometheusMetrics) RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
|
||||
m.syncPhaseDuration.WithLabelValues(phase).Observe(duration.Seconds())
|
||||
m.next.RecordSyncPhase(ctx, agentInfo, phase, duration)
|
||||
}
|
||||
|
||||
// RecordLoginDuration implements metricsImplementation.
|
||||
func (m *prometheusMetrics) RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
|
||||
m.loginDuration.WithLabelValues(strconv.FormatBool(success)).Observe(duration.Seconds())
|
||||
m.next.RecordLoginDuration(ctx, agentInfo, duration, success)
|
||||
}
|
||||
|
||||
// Export implements metricsImplementation by delegating to the wrapped
|
||||
// implementation; Prometheus metrics are pulled via the registry instead.
|
||||
func (m *prometheusMetrics) Export(w io.Writer) error {
|
||||
return m.next.Export(w)
|
||||
}
|
||||
|
||||
// Reset implements metricsImplementation by delegating to the wrapped
|
||||
// implementation; Prometheus metrics must not be cleared on push.
|
||||
func (m *prometheusMetrics) Reset() {
|
||||
m.next.Reset()
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netevents"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
)
|
||||
@@ -95,9 +95,9 @@ type ConnConfig struct {
|
||||
// ICEConfig ICE protocol configuration
|
||||
ICEConfig icemaker.Config
|
||||
|
||||
// NetworkState gates the reconnection guard on OS-reported network
|
||||
// NetMgr gates the reconnection guard on OS-reported network
|
||||
// availability; nil disables gating.
|
||||
NetworkState *netstate.State
|
||||
NetMgr *netevents.Manager
|
||||
}
|
||||
|
||||
type Conn struct {
|
||||
@@ -259,7 +259,7 @@ func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
|
||||
conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer)
|
||||
}
|
||||
|
||||
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetworkState)
|
||||
conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetMgr)
|
||||
|
||||
conn.wg.Add(1)
|
||||
go func() {
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
)
|
||||
|
||||
// ConnStatus represents the connection state as seen by the guard.
|
||||
@@ -24,6 +22,12 @@ const (
|
||||
|
||||
type connStatusFunc func() ConnStatus
|
||||
|
||||
// NetworkWatcher is the availability view the guard gates reconnects on.
|
||||
type NetworkWatcher interface {
|
||||
IsOnline() bool
|
||||
Changed() <-chan struct{}
|
||||
}
|
||||
|
||||
// Guard is responsible for the reconnection logic.
|
||||
// It will trigger to send an offer to the peer then has connection issues.
|
||||
// Watch these events:
|
||||
@@ -37,22 +41,22 @@ type Guard struct {
|
||||
isConnectedOnAllWay connStatusFunc
|
||||
timeout time.Duration
|
||||
srWatcher *SRWatcher
|
||||
// netState gates reconnect attempts on OS-reported network availability;
|
||||
// netWatcher gates reconnect attempts on OS-reported network availability;
|
||||
// nil disables gating.
|
||||
netState *netstate.State
|
||||
netWatcher NetworkWatcher
|
||||
relayedConnDisconnected chan struct{}
|
||||
iCEConnDisconnected chan struct{}
|
||||
}
|
||||
|
||||
// NewGuard creates a reconnection guard for a peer connection. A nil netState
|
||||
// NewGuard creates a reconnection guard for a peer connection. A nil netWatcher
|
||||
// disables network availability gating.
|
||||
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netState *netstate.State) *Guard {
|
||||
func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netWatcher NetworkWatcher) *Guard {
|
||||
return &Guard{
|
||||
log: log,
|
||||
isConnectedOnAllWay: isConnectedFn,
|
||||
timeout: timeout,
|
||||
srWatcher: srWatcher,
|
||||
netState: netState,
|
||||
netWatcher: netWatcher,
|
||||
relayedConnDisconnected: make(chan struct{}, 1),
|
||||
iCEConnDisconnected: make(chan struct{}, 1),
|
||||
}
|
||||
@@ -104,14 +108,17 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
|
||||
iceState := &iceRetryState{log: g.log}
|
||||
defer iceState.reset()
|
||||
|
||||
netChanged := g.netState.Changed()
|
||||
var netChanged <-chan struct{}
|
||||
if g.netWatcher != nil {
|
||||
netChanged = g.netWatcher.Changed()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tickerChannel:
|
||||
// skip attempts while the OS reports no usable network; the
|
||||
// netChanged case below resumes the loop once it returns
|
||||
if !g.netState.IsOnline() {
|
||||
if g.netWatcher != nil && !g.netWatcher.IsOnline() {
|
||||
continue
|
||||
}
|
||||
switch g.isConnectedOnAllWay() {
|
||||
@@ -152,8 +159,8 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
|
||||
|
||||
case <-netChanged:
|
||||
// Re-arm for the next transition before acting on this one.
|
||||
netChanged = g.netState.Changed()
|
||||
if !g.netState.IsOnline() {
|
||||
netChanged = g.netWatcher.Changed()
|
||||
if !g.netWatcher.IsOnline() {
|
||||
continue
|
||||
}
|
||||
// Ticks skipped while offline drove the backoff towards its
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/netstate"
|
||||
"github.com/netbirdio/netbird/client/netevents/netstate"
|
||||
)
|
||||
|
||||
// newTestGuardWithNetState builds a guard with a realistic MaxInterval: the
|
||||
|
||||
@@ -1167,6 +1167,18 @@ func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo
|
||||
return maps.Clone(d.resolvedDomainsStates)
|
||||
}
|
||||
|
||||
// GetPeerStates returns a snapshot of all known peer states, including offline peers.
|
||||
func (d *Status) GetPeerStates() []State {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
|
||||
states := make([]State, 0, d.numOfPeers())
|
||||
for _, state := range d.peers {
|
||||
states = append(states, state)
|
||||
}
|
||||
return append(states, d.offlinePeers...)
|
||||
}
|
||||
|
||||
// GetFullStatus gets full status
|
||||
func (d *Status) GetFullStatus() FullStatus {
|
||||
fullStatus := FullStatus{
|
||||
|
||||
@@ -129,6 +129,28 @@ func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) {
|
||||
req.False(ok, "removed peer must not resolve by IPv6 tunnel address")
|
||||
}
|
||||
|
||||
// TestStatus_GetPeerStates_IncludesOfflinePeers keeps the snapshot in line with
|
||||
// GetFullStatus: offline peers are known peers, so a consumer counting peers
|
||||
// must see the same total the status command reports.
|
||||
func TestStatus_GetPeerStates_IncludesOfflinePeers(t *testing.T) {
|
||||
status := NewRecorder("https://mgm")
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(status.AddPeer("pk-online", "online.netbird", "100.64.0.10", "fd00::1"))
|
||||
status.ReplaceOfflinePeers([]State{
|
||||
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", ConnStatus: StatusIdle},
|
||||
})
|
||||
|
||||
states := status.GetPeerStates()
|
||||
req.Len(states, 2, "snapshot must carry both the online and the offline peer")
|
||||
|
||||
keys := make([]string, 0, len(states))
|
||||
for _, s := range states {
|
||||
keys = append(keys, s.PubKey)
|
||||
}
|
||||
req.ElementsMatch([]string{"pk-online", "pk-offline"}, keys, "snapshot must carry both peers")
|
||||
}
|
||||
|
||||
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
||||
key := "abc"
|
||||
fqdn := "peer-a.netbird.local"
|
||||
|
||||
@@ -64,6 +64,9 @@ type WorkerICE struct {
|
||||
|
||||
// portForwardAttempted tracks if we've already tried port forwarding this session
|
||||
portForwardAttempted bool
|
||||
|
||||
// dialFunc, when non-nil, replaces agentDial in connect(). Only for tests.
|
||||
dialFunc func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error)
|
||||
}
|
||||
|
||||
func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) {
|
||||
@@ -123,7 +126,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
|
||||
w.log.Errorf("failed to create new session ID: %s", err)
|
||||
}
|
||||
w.sessionID = sessionID
|
||||
w.agent = nil
|
||||
w.abandonNegotiation()
|
||||
}
|
||||
|
||||
var preferredCandidateTypes []ice.CandidateType
|
||||
@@ -151,7 +154,9 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
|
||||
w.remoteSessionID = ""
|
||||
}
|
||||
|
||||
go w.connect(dialerCtx, agent, remoteOfferAnswer)
|
||||
// Capture the cancel func at spawn time: connect reads it from the argument
|
||||
// instead of the field, which a newer OnNewOffer may already have replaced.
|
||||
go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer)
|
||||
}
|
||||
|
||||
// OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer.
|
||||
@@ -200,16 +205,16 @@ func (w *WorkerICE) Close() {
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
|
||||
if w.agent == nil {
|
||||
return
|
||||
if w.agent != nil {
|
||||
w.agentDialerCancel()
|
||||
if err := w.agent.Close(); err != nil {
|
||||
w.log.Warnf("failed to close ICE agent: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.agentDialerCancel()
|
||||
if err := w.agent.Close(); err != nil {
|
||||
w.log.Warnf("failed to close ICE agent: %s", err)
|
||||
}
|
||||
|
||||
w.agent = nil
|
||||
// Unconditional: a dial goroutine racing this Close skips its own cleanup
|
||||
// (closeAgent finds a nil agent), so the flags must be dropped here too or
|
||||
// the reconnection guard reads the stale state as Connected forever.
|
||||
w.abandonNegotiation()
|
||||
}
|
||||
|
||||
func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) {
|
||||
@@ -247,31 +252,52 @@ func (w *WorkerICE) SessionID() ICESessionID {
|
||||
// will block until connection succeeded
|
||||
// but it won't release if ICE Agent went into Disconnected or Failed state,
|
||||
// so we have to cancel it with the provided context once agent detected a broken connection
|
||||
func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) {
|
||||
func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) {
|
||||
w.log.Debugf("gather candidates")
|
||||
if err := agent.GatherCandidates(); err != nil {
|
||||
w.log.Warnf("failed to gather candidates: %s", err)
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
w.closeAgent(agent, dialerCancel)
|
||||
return
|
||||
}
|
||||
|
||||
w.log.Debugf("agent dial")
|
||||
remoteConn, err := w.agentDial(ctx, agent, remoteOfferAnswer)
|
||||
dial := func(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (net.Conn, error) {
|
||||
return w.agentDial(ctx, agent, remoteOfferAnswer)
|
||||
}
|
||||
if w.dialFunc != nil {
|
||||
dial = w.dialFunc
|
||||
}
|
||||
remoteConn, err := dial(ctx, agent, remoteOfferAnswer)
|
||||
if err != nil {
|
||||
w.log.Debugf("failed to dial the remote peer: %s", err)
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
w.closeAgent(agent, dialerCancel)
|
||||
return
|
||||
}
|
||||
w.log.Debugf("agent dial succeeded")
|
||||
|
||||
// A newer negotiation may have replaced our agent while agentDial was
|
||||
// blocked. Drop the dead connection before running pair retrieval, port
|
||||
// punching or candidate work against a closed agent. The commit-point
|
||||
// check below still guards a replacement arriving after this point.
|
||||
w.muxAgent.Lock()
|
||||
stale := w.agent != agent
|
||||
w.muxAgent.Unlock()
|
||||
if stale {
|
||||
if err := remoteConn.Close(); err != nil {
|
||||
w.log.Warnf("failed to close stale ICE connection: %s", err)
|
||||
}
|
||||
w.log.Warnf("discarding connection from a stale ICE negotiation")
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := agent.GetSelectedCandidatePair()
|
||||
if err != nil {
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
w.closeAgent(agent, dialerCancel)
|
||||
return
|
||||
}
|
||||
if pair == nil {
|
||||
w.log.Warnf("selected candidate pair is nil, cannot proceed")
|
||||
w.closeAgent(agent, w.agentDialerCancel)
|
||||
w.closeAgent(agent, dialerCancel)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -301,11 +327,27 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent
|
||||
|
||||
w.log.Infof("connection succeeded with offer session: %s", remoteOfferAnswer.SessionIDString())
|
||||
w.muxAgent.Lock()
|
||||
// Authoritative ownership guard: a negotiation that lost w.agent to a newer
|
||||
// one between the post-dial check and the commit must not clear agentConnecting,
|
||||
// record lastSuccess or report the connection, so the state commit has to be
|
||||
// atomic with the check.
|
||||
if w.agent != agent {
|
||||
w.muxAgent.Unlock()
|
||||
if err := remoteConn.Close(); err != nil {
|
||||
w.log.Warnf("failed to close stale ICE connection: %s", err)
|
||||
}
|
||||
w.log.Warnf("discarding connection from a stale ICE negotiation")
|
||||
return
|
||||
}
|
||||
w.agentConnecting = false
|
||||
w.lastSuccess = time.Now()
|
||||
w.muxAgent.Unlock()
|
||||
|
||||
// todo: the potential problem is a race between the onConnectionStateChange
|
||||
// and the delivery below: after this unlock, a newer offer can replace
|
||||
// w.agent before onICEConnectionIsReady runs, delivering this (now stale)
|
||||
// connection. The newer negotiation overwrites it with its own delivery,
|
||||
// so the window only ever downgrades an endpoint transiently.
|
||||
w.conn.onICEConnectionIsReady(selectedPriority(pair), ci)
|
||||
}
|
||||
|
||||
@@ -321,20 +363,32 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C
|
||||
sessionChanged := w.remoteSessionChanged
|
||||
w.remoteSessionChanged = false
|
||||
|
||||
// Only the owner of the current session may reset its state: a stale dial
|
||||
// goroutine waking after a newer attempt must not clobber it.
|
||||
if w.agent == agent {
|
||||
// consider to remove from here and move to the OnNewOffer
|
||||
sessionID, err := NewICESessionID()
|
||||
if err != nil {
|
||||
w.log.Errorf("failed to create new session ID: %s", err)
|
||||
}
|
||||
w.sessionID = sessionID
|
||||
w.agent = nil
|
||||
w.agentConnecting = false
|
||||
w.remoteSessionID = ""
|
||||
w.abandonNegotiation()
|
||||
}
|
||||
return sessionChanged
|
||||
}
|
||||
|
||||
// abandonNegotiation drops all recorded ICE session state so the worker treats the
|
||||
// next offer as a fresh start instead of a duplicate of a dead negotiation. The
|
||||
// agent and agentConnecting flags must change together: leaving one stale wedges
|
||||
// the reconnection guard into reporting Connected forever. It neither cancels an
|
||||
// in-flight dial nor closes an agent — callers dispose of those themselves first,
|
||||
// so a stale goroutine can never cancel another session's dial through this path.
|
||||
// Caller must hold muxAgent.
|
||||
func (w *WorkerICE) abandonNegotiation() {
|
||||
w.agent = nil
|
||||
w.agentConnecting = false
|
||||
w.remoteSessionID = ""
|
||||
}
|
||||
|
||||
func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) {
|
||||
// wait local endpoint configuration
|
||||
time.Sleep(time.Second)
|
||||
|
||||
257
client/internal/peer/worker_ice_close_test.go
Normal file
257
client/internal/peer/worker_ice_close_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
signal "github.com/netbirdio/netbird/shared/signal/client"
|
||||
sProto "github.com/netbirdio/netbird/shared/signal/proto"
|
||||
)
|
||||
|
||||
// stubSignalClient satisfies signal.Client as a no-op so the candidate
|
||||
// goroutine spawned by a real GatherCandidates never dereferences a nil
|
||||
// signaler in tests.
|
||||
type stubSignalClient struct{}
|
||||
|
||||
func (stubSignalClient) Close() error { return nil }
|
||||
func (stubSignalClient) StreamConnected() bool { return false }
|
||||
func (stubSignalClient) GetStatus() signal.Status { return signal.StreamDisconnected }
|
||||
func (stubSignalClient) Receive(context.Context, func(*sProto.Message) error) error { return nil }
|
||||
func (stubSignalClient) Ready() bool { return false }
|
||||
func (stubSignalClient) IsHealthy() bool { return false }
|
||||
func (stubSignalClient) WaitStreamConnected(context.Context) {}
|
||||
func (stubSignalClient) SendToStream(*sProto.EncryptedMessage) error { return nil }
|
||||
func (stubSignalClient) Send(*sProto.Message) error { return nil }
|
||||
func (stubSignalClient) SetOnReconnectedListener(func()) {}
|
||||
|
||||
// newTestWorkerICE builds a worker with real pion plumbing and no-op signaling.
|
||||
func newTestWorkerICE(t *testing.T) *WorkerICE {
|
||||
t.Helper()
|
||||
|
||||
config := connConf
|
||||
stunTurn := &icemaker.StunTurn{}
|
||||
stunTurn.Store(nil)
|
||||
config.ICEConfig.StunTurn = stunTurn
|
||||
|
||||
w, err := NewWorkerICE(context.Background(), log.WithField("test", t.Name()), config, nil,
|
||||
NewSignaler(stubSignalClient{}, wgtypes.Key{}), nil, nil, false)
|
||||
require.NoError(t, err, "worker setup must succeed")
|
||||
return w
|
||||
}
|
||||
|
||||
// TestWorkerICE_CloseDuringDial_ClearsConnectingFlag drives the teardown race
|
||||
// through the real dial goroutine instead of simulating its cleanup.
|
||||
//
|
||||
// The real-world sequence this models:
|
||||
// 1. OnNewOffer starts a negotiation: agent set, agentConnecting = true,
|
||||
// go connect()
|
||||
// 2. The network dies and connect() stays blocked inside GatherCandidates/Dial
|
||||
// 3. A WG handshake timeout calls Close(): the agent is released and the dial
|
||||
// context cancelled, but agentConnecting is not reset
|
||||
// 4. The real goroutine wakes with an error and runs its own cleanup
|
||||
// (closeAgent), where `w.agent == agent` is now false, so the flag reset
|
||||
// is skipped
|
||||
//
|
||||
// There is no remote responder, so Dial can never succeed: whatever point the
|
||||
// goroutine is at, closing first forces it down the error path. Before the fix
|
||||
// the flag stays true forever and the deadline below expires.
|
||||
func TestWorkerICE_CloseDuringDial_ClearsConnectingFlag(t *testing.T) {
|
||||
w := newTestWorkerICE(t)
|
||||
|
||||
sid := ICESessionID("test-session-id")
|
||||
w.OnNewOffer(&OfferAnswer{
|
||||
IceCredentials: IceCredentials{
|
||||
UFrag: "testufrag",
|
||||
Pwd: "testpwdtestpwdtestpwd12",
|
||||
},
|
||||
SessionID: &sid,
|
||||
})
|
||||
require.True(t, w.InProgress(), "OnNewOffer must mark the negotiation as in progress")
|
||||
|
||||
// Teardown wins the race while connect() is still running.
|
||||
w.Close()
|
||||
|
||||
// Close drops the flags synchronously, so the assertion below does not
|
||||
// converge on the goroutine: the deadline only absorbs the dial goroutine
|
||||
// waking up in the background, proving nothing re-wedges it afterwards.
|
||||
require.Eventually(t, func() bool {
|
||||
return !w.InProgress()
|
||||
}, 10*time.Second, 50*time.Millisecond,
|
||||
"Close must leave the negotiation idle even while the dial goroutine is still winding down")
|
||||
|
||||
// abandonNegotiation owns these three fields together; the worker is idle
|
||||
// only when all of them are dropped.
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
assert.Nil(t, w.agent, "no agent may survive the teardown")
|
||||
assert.False(t, w.agentConnecting, "the connecting flag must match the nil agent")
|
||||
assert.Empty(t, w.remoteSessionID, "a dead session's remote ID must not linger")
|
||||
}
|
||||
|
||||
// TestWorkerICE_CloseClearsResidualConnectingState covers Close on a worker whose
|
||||
// agent is already gone but whose flag is stuck on true, e.g. after an aborted
|
||||
// recreate in OnNewOffer or after a first Close raced a dial goroutine.
|
||||
func TestWorkerICE_CloseClearsResidualConnectingState(t *testing.T) {
|
||||
w := newTestWorkerICE(t)
|
||||
|
||||
w.muxAgent.Lock()
|
||||
w.agentConnecting = true
|
||||
w.muxAgent.Unlock()
|
||||
|
||||
w.Close()
|
||||
|
||||
assert.False(t, w.InProgress(), "Close must drop residual connecting state even without a live agent")
|
||||
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
assert.Nil(t, w.agent)
|
||||
assert.False(t, w.agentConnecting)
|
||||
assert.Empty(t, w.remoteSessionID)
|
||||
}
|
||||
|
||||
// TestWorkerICE_StaleCloseAgentKeepsCurrentSession pins the ownership guard in
|
||||
// closeAgent: a late-waking dial goroutine from an older session must not reset
|
||||
// the state of a newer negotiation that reused the worker. The newer session
|
||||
// must survive wholesale - agent, flag and remote session identity alike.
|
||||
func TestWorkerICE_StaleCloseAgentKeepsCurrentSession(t *testing.T) {
|
||||
w := newTestWorkerICE(t)
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
sidA := ICESessionID("session-a")
|
||||
w.OnNewOffer(&OfferAnswer{
|
||||
IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"},
|
||||
SessionID: &sidA,
|
||||
})
|
||||
w.muxAgent.Lock()
|
||||
oldAgent := w.agent
|
||||
oldCancel := w.agentDialerCancel
|
||||
w.muxAgent.Unlock()
|
||||
require.NotNil(t, oldAgent, "OnNewOffer must have created an ICE agent")
|
||||
|
||||
w.Close()
|
||||
|
||||
sidB := ICESessionID("session-b")
|
||||
w.OnNewOffer(&OfferAnswer{
|
||||
IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"},
|
||||
SessionID: &sidB,
|
||||
})
|
||||
require.True(t, w.InProgress(), "the second negotiation must be in flight")
|
||||
|
||||
w.muxAgent.Lock()
|
||||
newAgent := w.agent
|
||||
w.muxAgent.Unlock()
|
||||
|
||||
// The old dial goroutine finally wakes and cleans up its captured agent.
|
||||
w.closeAgent(oldAgent, oldCancel)
|
||||
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
assert.Same(t, newAgent, w.agent, "the current agent must be untouched by the stale cleanup")
|
||||
assert.True(t, w.agentConnecting, "the current negotiation must stay in flight")
|
||||
// Read live under the lock: a snapshot captured before the stale cleanup
|
||||
// would pass even if the cleanup wiped current state.
|
||||
assert.Equal(t, sidB, w.remoteSessionID, "the remote session identity must be preserved")
|
||||
}
|
||||
|
||||
// closeTrackConn records Close calls so a test can assert that a discarded
|
||||
// connection was actually released.
|
||||
type closeTrackConn struct {
|
||||
net.Conn
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (c *closeTrackConn) Close() error {
|
||||
c.closed.Store(true)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation pins the ownership guard
|
||||
// in connect()'s success path: a dial that came back after a newer negotiation
|
||||
// replaced the agent must discard its connection and leave the newer session's
|
||||
// state - agent, agentConnecting, remoteSessionID, lastSuccess - intact.
|
||||
//
|
||||
// The dial hook holds session A's goroutine open until session B is installed,
|
||||
// then returns a live connection, mimicking the vendored pion dial which hands
|
||||
// out a live *ice.Conn when a pair is selected without checking afterwards
|
||||
// whether the agent was replaced meanwhile. Releasing A's dial therefore
|
||||
// exercises the stale-success commit path deterministically instead of racing
|
||||
// real ICE.
|
||||
func TestWorkerICE_StaleDialSuccessKeepsNewerNegotiation(t *testing.T) {
|
||||
w := newTestWorkerICE(t)
|
||||
t.Cleanup(w.Close)
|
||||
|
||||
dialStarted := make(chan struct{})
|
||||
releaseDial := make(chan struct{})
|
||||
staleConn := &closeTrackConn{}
|
||||
|
||||
var calls atomic.Int32
|
||||
w.dialFunc = func(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) {
|
||||
if calls.Add(1) == 1 {
|
||||
// Session A: hold the goroutine open until session B is installed,
|
||||
// then return a live connection, mimicking the vendored pion dial
|
||||
// which hands out a live *ice.Conn once a pair is selected without
|
||||
// re-checking whether the agent was replaced meanwhile. Releasing
|
||||
// the dial therefore exercises the stale-success commit path
|
||||
// deterministically instead of racing real ICE.
|
||||
close(dialStarted)
|
||||
<-releaseDial
|
||||
client, _ := net.Pipe()
|
||||
staleConn.Conn = client
|
||||
return staleConn, nil
|
||||
}
|
||||
// A newer negotiation parks on its dialer context, cancelled by the
|
||||
// t.Cleanup Close at test end.
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
sidA := ICESessionID("session-a")
|
||||
w.OnNewOffer(&OfferAnswer{
|
||||
IceCredentials: IceCredentials{UFrag: "ufragaaaa", Pwd: "pwdpwdpwdpwdpwdpwdpwdp1"},
|
||||
SessionID: &sidA,
|
||||
})
|
||||
require.True(t, w.InProgress(), "session A must be in flight")
|
||||
|
||||
// Session A's goroutine is now parked in the dial hook.
|
||||
<-dialStarted
|
||||
|
||||
sidB := ICESessionID("session-b")
|
||||
w.OnNewOffer(&OfferAnswer{
|
||||
IceCredentials: IceCredentials{UFrag: "ufragbbbb", Pwd: "pwdpwdpwdpwdpwdpwdpwdp2"},
|
||||
SessionID: &sidB,
|
||||
})
|
||||
|
||||
w.muxAgent.Lock()
|
||||
agentB := w.agent
|
||||
w.lastSuccess = time.Time{}
|
||||
w.muxAgent.Unlock()
|
||||
require.NotNil(t, agentB, "session B must have created an ICE agent")
|
||||
require.True(t, w.InProgress(), "session B must be in flight")
|
||||
|
||||
// Release session A's dial: it must be recognized as stale and discarded.
|
||||
close(releaseDial)
|
||||
require.Eventually(t, func() bool {
|
||||
return staleConn.closed.Load()
|
||||
}, 10*time.Second, 10*time.Millisecond,
|
||||
"the stale connection must be closed by the ownership guard")
|
||||
|
||||
w.muxAgent.Lock()
|
||||
defer w.muxAgent.Unlock()
|
||||
assert.Same(t, agentB, w.agent, "session A must not uninstall session B's agent")
|
||||
assert.True(t, w.agentConnecting, "session A must not clear session B's connecting flag")
|
||||
assert.Equal(t, sidB, w.remoteSessionID, "session A must not clear session B's remote session identity")
|
||||
assert.True(t, w.lastSuccess.IsZero(), "session A must not record a success for session B")
|
||||
// The commit block guards agentConnecting, lastSuccess and
|
||||
// onICEConnectionIsReady together, so the state assertions above imply the
|
||||
// callback never ran for session A; the nil conn would have panicked the
|
||||
// stale goroutine on any invocation.
|
||||
}
|
||||
@@ -70,6 +70,7 @@ type ConfigInput struct {
|
||||
StateFilePath string
|
||||
PreSharedKey *string
|
||||
ServerSSHAllowed *bool
|
||||
RemoteJobsAllowed *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -103,6 +104,9 @@ type ConfigInput struct {
|
||||
DNSLabels domain.List
|
||||
|
||||
MTU *uint16
|
||||
|
||||
LocalMetricsEnabled *bool
|
||||
LocalMetricsAddress *string
|
||||
}
|
||||
|
||||
// Config Configuration type
|
||||
@@ -124,6 +128,7 @@ type Config struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed *bool
|
||||
RemoteJobsAllowed *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -144,6 +149,11 @@ type Config struct {
|
||||
|
||||
DNSLabels domain.List
|
||||
|
||||
// LocalMetricsEnabled enables the local Prometheus /metrics endpoint.
|
||||
LocalMetricsEnabled bool
|
||||
// LocalMetricsAddress is the listen address of the local /metrics endpoint.
|
||||
LocalMetricsAddress string
|
||||
|
||||
// SSHKey is a private SSH key in a PEM format
|
||||
SSHKey string
|
||||
|
||||
@@ -184,6 +194,12 @@ type Config struct {
|
||||
// Runtime-only: re-derived from MDM policy on each load, never persisted.
|
||||
LazyConnection string `json:"-"`
|
||||
|
||||
// DebugBundleUploadURL is the MDM-managed debug-bundle upload URL override.
|
||||
// When set, it takes precedence over the management-supplied upload URL for
|
||||
// remote debug bundle jobs. Runtime-only: re-derived from MDM policy on each
|
||||
// load, never persisted.
|
||||
DebugBundleUploadURL string `json:"-"`
|
||||
|
||||
MTU uint16
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values for
|
||||
@@ -217,6 +233,12 @@ func getConfigDir() (string, error) {
|
||||
}
|
||||
|
||||
configDir := filepath.Join(base, "netbird")
|
||||
// Under sudo this is the invoking user's directory and strictly read-only:
|
||||
// anything root creates in it would be root-owned and break the user's own
|
||||
// runs. Reads of a missing directory fall through to defaults.
|
||||
if sudoActive() {
|
||||
return configDir, nil
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -224,6 +246,16 @@ func getConfigDir() (string, error) {
|
||||
}
|
||||
|
||||
func baseConfigDir() (string, error) {
|
||||
if u, ok := sudoInvokingUser(); ok {
|
||||
return userBaseConfigDir(u)
|
||||
}
|
||||
// Fail closed instead of falling through to root's own config directory:
|
||||
// reading root's active-profile and email state for what is actually the
|
||||
// invoking user's invocation is the very confusion this resolution exists
|
||||
// to prevent.
|
||||
if sudoActive() {
|
||||
return "", fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root's config directory", os.Getenv(envSudoUser))
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
if u, err := user.Current(); err == nil && u.HomeDir != "" {
|
||||
return filepath.Join(u.HomeDir, "Library", "Application Support"), nil
|
||||
@@ -265,7 +297,10 @@ func createNewConfig(input ConfigInput) (*Config, error) {
|
||||
config := &Config{
|
||||
// defaults to false only for new (post 0.26) configurations
|
||||
ServerSSHAllowed: util.False(),
|
||||
WgPort: iface.DefaultWgPort,
|
||||
// Remote jobs are an explicit opt-in and default off, including for
|
||||
// legacy configs (a nil value materializes to false at connect time).
|
||||
RemoteJobsAllowed: util.False(),
|
||||
WgPort: iface.DefaultWgPort,
|
||||
}
|
||||
|
||||
if _, err := config.apply(input); err != nil {
|
||||
@@ -388,6 +423,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.LocalMetricsEnabled != nil && *input.LocalMetricsEnabled != config.LocalMetricsEnabled {
|
||||
log.Infof("switching local metrics to %t", *input.LocalMetricsEnabled)
|
||||
config.LocalMetricsEnabled = *input.LocalMetricsEnabled
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.LocalMetricsAddress != nil && *input.LocalMetricsAddress != config.LocalMetricsAddress {
|
||||
log.Infof("switching local metrics address to %s", *input.LocalMetricsAddress)
|
||||
config.LocalMetricsAddress = *input.LocalMetricsAddress
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) {
|
||||
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
|
||||
config.NetworkMonitor = input.NetworkMonitor
|
||||
@@ -456,6 +503,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) {
|
||||
if *input.RemoteJobsAllowed {
|
||||
log.Infof("enabling remote jobs")
|
||||
} else {
|
||||
log.Infof("disabling remote jobs")
|
||||
}
|
||||
config.RemoteJobsAllowed = input.RemoteJobsAllowed
|
||||
updated = true
|
||||
} else if config.RemoteJobsAllowed == nil {
|
||||
// Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config
|
||||
// with no value defaults to disabled rather than being turned on.
|
||||
config.RemoteJobsAllowed = util.False()
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) {
|
||||
if *input.EnableSSHRoot {
|
||||
log.Infof("enabling SSH root login")
|
||||
@@ -665,6 +727,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
// for the key, so per-field rejection of user writes still applies).
|
||||
func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
config.policy = policy
|
||||
|
||||
// DebugBundleUploadURL is a runtime-only override re-derived from MDM on
|
||||
// every apply. Resolve it unconditionally (before the IsEmpty early return)
|
||||
// so a policy that drops the key, becomes empty, or carries an invalid
|
||||
// value can never leave a previously-enforced upload target active on a
|
||||
// reused Config instance.
|
||||
config.DebugBundleUploadURL = mdmDebugBundleUploadURL(policy)
|
||||
|
||||
if policy.IsEmpty() {
|
||||
return
|
||||
}
|
||||
@@ -712,12 +782,19 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
}
|
||||
|
||||
applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
|
||||
applyBool(mdm.KeyRemoteJobsAllowed, func(v bool) { bv := v; config.RemoteJobsAllowed = &bv })
|
||||
applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
|
||||
applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
|
||||
applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
|
||||
applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
|
||||
applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
|
||||
applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
|
||||
applyBool(mdm.KeyEnableLocalMetrics, func(v bool) { config.LocalMetricsEnabled = v })
|
||||
|
||||
if v, ok := policy.GetString(mdm.KeyLocalMetricsAddress); ok {
|
||||
config.LocalMetricsAddress = v
|
||||
logApplied(mdm.KeyLocalMetricsAddress, v)
|
||||
}
|
||||
|
||||
if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
|
||||
// REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
|
||||
@@ -739,6 +816,52 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
config.LazyConnection = state
|
||||
logApplied(mdm.KeyLazyConnection, state)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ValidateBundleUploadURL sanity-checks a debug-bundle upload URL. An empty
|
||||
// value is accepted — the executor falls back to the default upload service. A
|
||||
// non-empty value must be a well-formed https URL with a host; a malformed
|
||||
// value or a plaintext scheme is rejected. It deliberately does not constrain
|
||||
// which host may receive the bundle. This is the single source of truth for the
|
||||
// rule, shared by the remote-job executor (client/internal) and the MDM policy
|
||||
// override below so the two validation paths cannot drift.
|
||||
func ValidateBundleUploadURL(raw string) error {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse upload URL: %w", err)
|
||||
}
|
||||
// Hostname(), not Host: an authority like ":443" is non-empty but has no
|
||||
// host, and would fail the actual upload.
|
||||
if parsed.Scheme != "https" || parsed.Hostname() == "" {
|
||||
return fmt.Errorf("upload URL must be an https URL with a host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mdmDebugBundleUploadURL resolves the MDM-enforced debug-bundle upload URL
|
||||
// override from the policy, returning the empty string when the policy does
|
||||
// not carry a valid KeyBundleUploadURL. An absent or invalid value fails
|
||||
// closed to "" so it falls back to the management-supplied or default upload
|
||||
// target rather than a previously-enforced one. The URL is never logged: it
|
||||
// can embed credentials or signed query tokens (KeyBundleUploadURL is in
|
||||
// mdm.SecretKeys).
|
||||
func mdmDebugBundleUploadURL(policy *mdm.Policy) string {
|
||||
v, ok := policy.GetString(mdm.KeyBundleUploadURL)
|
||||
if !ok || v == "" {
|
||||
return ""
|
||||
}
|
||||
// Must be a well-formed https URL with a host, matching the client's
|
||||
// remote-job upload-URL validation (shared validator, single source of truth).
|
||||
if err := ValidateBundleUploadURL(v); err != nil {
|
||||
log.Warnf("MDM debug bundle upload URL is invalid (must be an https URL with a host); ignoring the override")
|
||||
return ""
|
||||
}
|
||||
log.Infof("MDM override %s = ********** (secret)", mdm.KeyBundleUploadURL)
|
||||
return v
|
||||
}
|
||||
|
||||
// parseURL parses and validates the URL for the named service. The URL
|
||||
|
||||
@@ -130,6 +130,32 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
|
||||
}
|
||||
|
||||
func TestApply_MDMLocalMetrics(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
LocalMetricsEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
|
||||
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyLocalMetricsAddress))
|
||||
}
|
||||
|
||||
func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user