Merge remote-tracking branch 'origin/main' into modify-peer-reactivate

# Conflicts:
#	client/internal/conn_mgr.go
#	client/internal/engine.go
This commit is contained in:
Viktor Liu
2026-08-31 11:25:35 +02:00
599 changed files with 44312 additions and 8045 deletions

View File

@@ -12,6 +12,13 @@ on:
AWS issues it. Leave empty for the Sonnet 4.6 default.
required: false
default: ""
test_pattern:
description: >-
Package pattern to run. Defaults to the whole suite; narrow it to one
package (e.g. ./e2e/agentnetwork/...) when a run only needs that
package's answer and not the sixteen minutes the container suite costs.
required: false
default: "./e2e/..."
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -77,4 +84,8 @@ jobs:
GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }}
GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }}
GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }}
run: go test -tags e2e -timeout 40m -v ./e2e/...
# Read through an env var rather than interpolated into the run
# script: a dispatch input reaching a shell command directly is a
# script-injection seam, however trusted the dispatcher.
TEST_PATTERN: ${{ inputs.test_pattern || './e2e/...' }}
run: go test -tags e2e -timeout 40m -v "$TEST_PATTERN"

33
.github/workflows/buf.yml vendored Normal file
View 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

View File

@@ -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'

View File

@@ -1,72 +0,0 @@
name: Mobile
on:
push:
branches:
- main
- "release-*"
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
cancel-in-progress: true
jobs:
android_build:
name: "Android / Build"
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Setup Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
with:
cmdline-tools-version: 8512546
- name: Setup Java
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520
with:
java-version: "11"
distribution: "adopt"
- name: NDK Cache
id: ndk-cache
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: /usr/local/lib/android/sdk/ndk
key: ndk-cache-23.1.7779620
- name: Setup NDK
run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620"
- name: install gomobile
run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab
- name: gomobile init
run: gomobile init
- name: build android netbird lib
run: PATH=$PATH:$(go env GOPATH) gomobile bind -o $GITHUB_WORKSPACE/netbird.aar -javapkg=io.netbird.gomobile -ldflags="-checklinkname=0 -X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/io.netbird.client/cache/wireguard -X github.com/netbirdio/netbird/version.version=buildtest" $GITHUB_WORKSPACE/client/android
env:
CGO_ENABLED: 0
ANDROID_NDK_HOME: /usr/local/lib/android/sdk/ndk/23.1.7779620
ios_build:
name: "iOS / Build"
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: install gomobile
run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab
- name: gomobile init
run: gomobile init
- name: build iOS netbird lib
run: PATH=$PATH:$(go env GOPATH) gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=buildtest" -o ./NetBirdSDK.xcframework ./client/ios/NetBirdSDK
env:
CGO_ENABLED: 0

78
.github/workflows/no-new-replace.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: No New Replace Directives
on:
pull_request:
paths:
- "go.mod"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
cancel-in-progress: true
jobs:
check-replace-directives:
name: check-replace-directives
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
- name: Compare replace directives against the base branch
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
# A replace directive only applies when this module is the main
# module. Anything importing netbird as a library, the embedded
# clients among them, resolves the replaced path upstream instead and
# fails to build against whatever the replacement provides. Requiring
# a fork under its own module path avoids that; a replace does not.
#
# go.mod is parsed rather than diffed so that reordering, comments and
# single-line versus block syntax do not register as changes.
#
# Versions are part of the key because a replace can be scoped to one
# version of a module. Keyed on paths alone, retargeting such a
# directive at a different version would read as unchanged.
list_replaces() {
go mod edit -json "$1" \
| jq -r '
def ref: .Path + (if (.Version // "") == "" then "" else " " + .Version end);
(.Replace // [])[] | "\(.Old | ref) => \(.New | ref)"
' \
| sort
}
git show "${BASE_SHA}:go.mod" > /tmp/base-go.mod
list_replaces /tmp/base-go.mod > /tmp/base-replaces
list_replaces go.mod > /tmp/head-replaces
added=$(comm -13 /tmp/base-replaces /tmp/head-replaces)
if [ -n "$added" ]; then
echo "::error::This PR adds a replace directive to go.mod:"
echo "$added" | sed 's/^/ /'
echo ""
echo "A replace directive applies only to the main module, so it does not"
echo "reach anything that imports netbird as a library. Require the module"
echo "under a path you control instead, as done for github.com/netbirdio/go-nat."
exit 1
fi
removed=$(comm -23 /tmp/base-replaces /tmp/head-replaces)
if [ -n "$removed" ]; then
echo "This PR removes replace directives:"
echo "$removed" | sed 's/^/ /'
fi
echo "No new replace directives."

View File

@@ -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 }}" }'

View File

@@ -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)

View File

@@ -40,6 +40,35 @@ You can then use this private endpoint to configure your AI agents, whether that
Full step-by-step setup:
**https://docs.netbird.io/agent-network/quickstart**
## Client settings that don't follow the endpoint
Most of an agent's traffic follows the base URL you hand it, but a few
client-side checks call their vendor directly and never reach the proxy. On a
network that blocks direct egress they fail even though inference works, so
they are worth setting once when you roll the endpoint out.
For Claude Code:
- **Fast mode** checks availability against `api.anthropic.com` rather than the
configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the
agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when
the proxy injects the real provider key) or when a TLS-inspecting proxy
answers the check itself. Set
`CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the
connection outright. Fast mode is an Anthropic-API feature, so it is
unavailable on a Bedrock- or Vertex-backed endpoint whatever you set.
- **Model discovery** is off by default. Set
`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the
models your policies authorise; the proxy filters the response to that set.
The client gives discovery a three-second budget and treats any redirect as
a failure, so the endpoint must serve `/v1/models` directly.
- **The WebFetch domain safety check** also calls `api.anthropic.com` directly
and is unaffected by the variables above.
Allowing direct egress to `api.anthropic.com` covers the network cases but not
the credential one, where the check reaches Anthropic and is rejected because
the agent presents a proxy-issued key.
## Architecture
Agent Network is built on two existing NetBird capabilities:

View File

@@ -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.

View File

@@ -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

View File

@@ -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}
}

View File

@@ -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
}

View File

@@ -0,0 +1,106 @@
package android
// Split tunnelling modes, stored as strings so an unknown value written by a
// newer build degrades to "off" rather than to some other mode's behaviour.
const (
SplitTunnelModeOff = "off"
SplitTunnelModeExclude = "exclude"
SplitTunnelModeInclude = "include"
)
type splitTunnelSection struct {
Mode string `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.
type SplitTunnelSettings struct {
Mode string
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
}
func normalizeSplitTunnelMode(mode string) string {
switch mode {
case SplitTunnelModeExclude, SplitTunnelModeInclude:
return mode
default:
return SplitTunnelModeOff
}
}
func settingsFromSection(section splitTunnelSection) *SplitTunnelSettings {
out := NewSplitTunnelSettings()
out.Mode = 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(settings.Mode),
Excluded: packagesOf(settings.Excluded),
Included: packagesOf(settings.Included),
}
}

View 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, &section); 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))
}

View File

@@ -0,0 +1,109 @@
package android
import (
"reflect"
"testing"
)
func TestNormalizeSplitTunnelMode(t *testing.T) {
tests := []struct {
name string
mode string
want string
}{
{name: "exclude is kept", mode: SplitTunnelModeExclude, want: SplitTunnelModeExclude},
{name: "include is kept", mode: SplitTunnelModeInclude, want: SplitTunnelModeInclude},
{name: "off is kept", mode: SplitTunnelModeOff, want: SplitTunnelModeOff},
{name: "empty falls back to off", mode: "", want: SplitTunnelModeOff},
{name: "a mode from a newer build falls back to off", mode: "only-work-apps", want: SplitTunnelModeOff},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeSplitTunnelMode(tt.mode); got != tt.want {
t.Errorf("normalizeSplitTunnelMode(%q) = %q, want %q", tt.mode, got, tt.want)
}
})
}
}
func TestSettingsFromSection(t *testing.T) {
got := settingsFromSection(splitTunnelSection{
Mode: SplitTunnelModeExclude,
Excluded: []string{"com.example.a", "com.example.b"},
Included: []string{"com.example.c"},
})
if got.Mode != SplitTunnelModeExclude {
t.Errorf("mode = %q, want %q", 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 = %q, want %q", 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))
}
}
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 = %q, want %q", 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))
}
}
func TestSectionFromNilSettings(t *testing.T) {
section := sectionFromSettings(nil)
if section.Mode != SplitTunnelModeOff {
t.Errorf("mode = %q, want %q", section.Mode, SplitTunnelModeOff)
}
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")
}
}

View File

@@ -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")

View File

@@ -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 {

View File

@@ -398,26 +398,10 @@ 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
}
// 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 +424,30 @@ 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)
if cmd.Flag(interfaceNameFlag).Changed {
if err := parseInterfaceName(interfaceName); err != nil {
log.Errorf("parse interface name: %v", err)
@@ -499,6 +507,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
}
@@ -616,9 +631,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 +696,20 @@ 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)
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

View File

@@ -85,12 +85,24 @@ 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
// when the embedded client must never act as a stepping stone into
// the host's local network (e.g. the proxy's overlay peer).
BlockLANAccess bool
// LazyConnectionEnabled is a tri-state local override for lazy connections,
// mirroring the NB_LAZY_CONN env var. Nil defers to the management feature
// flag; a set value overrides it in both directions. A short-lived client
// that reaches only a few known peers can set this to false, so its peers
// connect eagerly and the first request does not wait for the connection to
// be established.
LazyConnectionEnabled *bool
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
WireguardPort *int
// MTU is the MTU for the tunnel interface.
@@ -203,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,
@@ -220,6 +234,15 @@ func New(opts Options) (*Client, error) {
config.PrivateKey = opts.PrivateKey
}
if opts.LazyConnectionEnabled != nil {
// Runtime-only override, read back through lazyconn.ParseState; a set value
// wins over the management feature flag in both directions.
config.LazyConnection = "off"
if *opts.LazyConnectionEnabled {
config.LazyConnection = "on"
}
}
if opts.Performance.PreallocatedBuffersPerPool != nil {
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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{}
}

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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))

View 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)
}
})
}
}

View File

@@ -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,16 +188,19 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) {
}
}
// AddPeerConn stores the peer connection and registers it with the lazy connection manager.
// 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.
// active marks a peer whose connection was already established, e.g. one re-added after a
// network map modification: it is registered as active and its connection stays open, so the
// remote side does not have to signal a wake for a connection it still considers established.
func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, active bool) (exists bool) {
func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, permanent, active bool) (exists bool) {
if success := e.peerStore.AddPeerConn(peerKey, conn); !success {
return true
}
if !e.isStartedWithLazyMgr() {
if !e.isStartedWithLazyMgr() || permanent {
e.openConn(ctx, conn)
return
}
@@ -312,6 +311,8 @@ func (e *ConnMgr) Close() {
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil
e.lazyConnMgrMu.Unlock()
e.appliedExcludeList = nil
}
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
@@ -325,6 +326,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()
@@ -332,46 +335,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
}

View File

@@ -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)
}
}
})
}
}

View File

@@ -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(
@@ -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
@@ -723,7 +711,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 +720,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)

View 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)
}

View 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)
})
}
}

View File

@@ -737,6 +737,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 {

View File

@@ -6,8 +6,10 @@ import (
"fmt"
"io"
"net/netip"
"os"
"os/exec"
"slices"
"strconv"
"strings"
"syscall"
"time"
@@ -34,10 +36,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`
@@ -53,8 +61,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
@@ -293,6 +317,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 config.RouteAll {
if err := r.addDNSSetupForAll(config.ServerIP); err != nil {
return fmt.Errorf("add dns setup: %w", err)
@@ -318,8 +349,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 {
@@ -397,6 +442,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)
@@ -416,7 +497,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)
}
@@ -514,8 +599,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 {
@@ -598,9 +686,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)
@@ -636,6 +732,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)

View File

@@ -94,6 +94,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 := &registryConfigurator{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 := &registryConfigurator{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 {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -2,21 +2,17 @@ package ebpf
import (
_ "embed"
"fmt"
"net"
"sync"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
"github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
const (
xdpProgName = "nb_xdp_prog"
mapKeyFeatures uint32 = 0
featureFlagWGProxy = 0b00000001
@@ -72,50 +68,21 @@ func (tf *GeneralManager) loadXdp() error {
return err
}
// lo has no native XDP, so the program runs in generic mode. Unless it
// declares multi-buffer support the kernel must linearize every non-linear
// skb before running it. Loopback packets are up to 64 KB, so that is a
// contiguous GFP_ATOMIC allocation per packet, and when it fails the packet
// is dropped before the program runs, stalling local TCP connections.
// Multi-buffer XDP in generic mode requires kernel 6.3, so fall back to a
// plain attach when the kernel rejects it.
err = tf.attachXdp(iFace.Index, true)
if err == nil {
return nil
}
log.Debugf("failed to attach multi-buffer xdp program, retrying without it: %s", err)
return tf.attachXdp(iFace.Index, false)
}
func (tf *GeneralManager) attachXdp(iFaceIndex int, multiBuffer bool) error {
spec, err := loadBpf()
// load pre-compiled programs into the kernel.
err = loadBpfObjects(&tf.bpfObjs, nil)
if err != nil {
return fmt.Errorf("load bpf spec: %w", err)
}
if multiBuffer {
prog, ok := spec.Programs[xdpProgName]
if !ok {
return fmt.Errorf("program %s not found in bpf spec", xdpProgName)
}
prog.Flags |= unix.BPF_F_XDP_HAS_FRAGS
}
if err := spec.LoadAndAssign(&tf.bpfObjs, nil); err != nil {
return fmt.Errorf("load bpf objects: %w", err)
return err
}
tf.link, err = link.AttachXDP(link.XDPOptions{
Program: tf.bpfObjs.NbXdpProg,
Interface: iFaceIndex,
Interface: iFace.Index,
})
if err != nil {
if closeErr := tf.bpfObjs.Close(); closeErr != nil {
log.Debugf("failed to close bpf objects after xdp attach error: %s", closeErr)
}
_ = tf.bpfObjs.Close()
tf.link = nil
return fmt.Errorf("attach xdp: %w", err)
return err
}
return nil
}

View 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()
}

View 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
}

View 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)
}
}

View 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]
}

View 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")
}

View 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
}

View 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")
}

View 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
}

View 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
}

View 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
}

View 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}

View 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}

View 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 })
}

View 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)
}
}

View 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
}

View 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),
},
}
}

View File

@@ -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"
@@ -182,9 +182,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 +208,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 +345,7 @@ func NewEngine(
syncMsgMux: &sync.Mutex{},
config: config,
mobileDep: mobileDep,
netState: services.NetState,
netMgr: services.NetMgr,
STUNs: []*stun.URI{},
TURNs: []*stun.URI{},
networkSerial: 0,
@@ -1508,8 +1508,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 {
@@ -1575,8 +1579,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()
@@ -1594,8 +1597,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
@@ -1839,16 +1841,16 @@ 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, false)
if err != nil {
if err := e.addNewPeer(p, false); err != nil {
return err
}
}
return nil
}
// addNewPeer add peer if connection doesn't exist. active registers the peer with an
// already established connection instead of an idle lazy one.
// addNewPeer add peer if connection doesn't exist. A peer that is not lazy by
// policy gets an always-active connection instead. active registers the peer with
// an already established connection instead of an idle lazy one.
func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig, active bool) error {
peerKey := peerConfig.GetWgPubKey()
peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps()))
@@ -1883,7 +1885,8 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig, active bool)
log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err)
}
if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, active); exists {
permanent := !e.connMgr.PeerLazyDefault(peerConfig.GetLazyState())
if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, permanent, active); exists {
conn.Close(false)
return fmt.Errorf("peer already exists: %s", peerKey)
}
@@ -1916,8 +1919,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{
@@ -2675,46 +2678,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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -280,7 +280,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
},

View 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()
}

View 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

View File

@@ -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)
}

View 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
}

View 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
}

View File

@@ -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)
}
}

View 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
}

View File

@@ -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

View 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)
}

View 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)
})
}
}

View File

@@ -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) {

View File

@@ -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"
}

View File

@@ -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
}

View 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()
}

View File

@@ -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() {

View File

@@ -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

View File

@@ -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

View File

@@ -81,14 +81,19 @@ type Handshaker struct {
func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker {
h := &Handshaker{
log: log,
config: config,
signaler: signaler,
ice: ice,
relay: relay,
metricsStages: metricsStages,
remoteOffersCh: make(chan OfferAnswer),
remoteAnswerCh: make(chan OfferAnswer),
log: log,
config: config,
signaler: signaler,
ice: ice,
relay: relay,
metricsStages: metricsStages,
// Buffered by one so an offer or answer that arrives between Open launching
// the Listen goroutine and it reaching its receive is held rather than
// dropped. A peer activated by an incoming signal receives the remote's
// message in that window; an unbuffered channel skips it as "receiver not
// ready", and the connection cannot proceed until the remote re-sends.
remoteOffersCh: make(chan OfferAnswer, 1),
remoteAnswerCh: make(chan OfferAnswer, 1),
}
// assume remote supports ICE until we learn otherwise from received offers
h.remoteICESupported.Store(ice != nil)
@@ -162,29 +167,38 @@ func (h *Handshaker) SendOffer() error {
return h.sendOffer()
}
// OnRemoteOffer handles an offer from the remote peer and returns true if the message was accepted, false otherwise
// doesn't block, discards the message if connection wasn't ready
// OnRemoteOffer hands an offer to Listen without blocking, keeping only the most
// recent one if several arrive before Listen reads them.
func (h *Handshaker) OnRemoteOffer(offer OfferAnswer) {
select {
case h.remoteOffersCh <- offer:
return
default:
h.log.Warnf("skipping remote offer message because receiver not ready")
// connection might not be ready yet to receive so we ignore the message
return
}
enqueueLatest(h.remoteOffersCh, offer)
}
// OnRemoteAnswer handles an offer from the remote peer and returns true if the message was accepted, false otherwise
// doesn't block, discards the message if connection wasn't ready
// OnRemoteAnswer hands an answer to Listen without blocking, keeping only the most
// recent one if several arrive before Listen reads them.
func (h *Handshaker) OnRemoteAnswer(answer OfferAnswer) {
enqueueLatest(h.remoteAnswerCh, answer)
}
// enqueueLatest delivers msg on a one-slot channel without blocking. When the slot
// already holds an unread message the older one is discarded in favor of msg, so a
// message arriving before Listen starts reading is held rather than dropped, and
// the newest wins if several arrive first. Safe because there is a single producer
// (the engine loop): after draining the stale value the send always has room.
func enqueueLatest(ch chan OfferAnswer, msg OfferAnswer) {
select {
case h.remoteAnswerCh <- answer:
case ch <- msg:
return
default:
// connection might not be ready yet to receive so we ignore the message
h.log.Warnf("skipping remote answer message because receiver not ready")
return
}
select {
case <-ch:
default:
}
select {
case ch <- msg:
default:
}
}

View File

@@ -0,0 +1,63 @@
package peer
import (
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
func newTestHandshaker(t *testing.T) *Handshaker {
t.Helper()
// The tests exercise the answer path, whose Listen branch dispatches to the
// relay listener without sending an answer, so no signaler/ICE/relay is needed.
return NewHandshaker(log.WithField("test", t.Name()), ConnConfig{}, nil, nil, nil, nil)
}
// TestHandshakerHoldsSignalArrivingBeforeListen covers the case where a peer is
// activated by an incoming signal: the remote's offer/answer arrives in the same
// step that opens the connection, before the Listen loop starts reading. The
// message must be held rather than dropped, or the connection cannot proceed until
// the remote re-sends. This is the path taken when an eager peer connects to a
// lazily-managed one.
func TestHandshakerHoldsSignalArrivingBeforeListen(t *testing.T) {
h := newTestHandshaker(t)
processed := make(chan *OfferAnswer, 4)
h.AddRelayListener(func(o *OfferAnswer) { processed <- o })
// Delivered before Listen is reading, as when the peer is woken by the remote's
// signal and the message is delivered right after Open.
h.OnRemoteAnswer(OfferAnswer{WgListenPort: 51820})
go h.Listen(t.Context())
select {
case <-processed:
case <-time.After(2 * time.Second):
assert.Fail(t, "remote-answer dispatch: signal delivered before Listen was ready was dropped")
}
}
// TestHandshakerKeepsLatestSignalBeforeListen covers several signals arriving
// before Listen reads: the newest must win (matching the latest-offer contract),
// rather than the first being kept and later ones discarded.
func TestHandshakerKeepsLatestSignalBeforeListen(t *testing.T) {
h := newTestHandshaker(t)
processed := make(chan *OfferAnswer, 4)
h.AddRelayListener(func(o *OfferAnswer) { processed <- o })
h.OnRemoteAnswer(OfferAnswer{WgListenPort: 1111})
h.OnRemoteAnswer(OfferAnswer{WgListenPort: 2222})
go h.Listen(t.Context())
select {
case got := <-processed:
assert.Equal(t, 2222, got.WgListenPort, "remote-answer dispatch: the latest queued signal should be processed")
case <-time.After(2 * time.Second):
assert.Fail(t, "remote-answer dispatch: queued signal was dropped")
}
}

View File

@@ -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{

View File

@@ -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"

View File

@@ -389,6 +389,17 @@ func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) {
return
}
// A forwarded candidate only makes sense for an IPv4 mapping, which
// translates a port on the gateway's address. An IPv6 pinhole translates
// nothing: it unblocks the address ICE already gathers as a host candidate,
// so there is no second address to advertise. Injecting one here would also
// paste an IPv6 address onto whichever server-reflexive candidate arrived
// first, which is usually IPv4.
if mapping.ExternalIP != nil && mapping.ExternalIP.To4() == nil {
w.log.Debugf("skipping port-forwarded candidate: %s mapping is IPv6-only", mapping.NATType)
return
}
w.muxAgent.Lock()
if w.portForwardAttempted {
w.muxAgent.Unlock()

View File

@@ -10,10 +10,8 @@ import (
"sync"
"time"
"github.com/libp2p/go-nat"
"github.com/netbirdio/go-nat"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/portforward/pcp"
)
const (
@@ -168,6 +166,11 @@ func (m *Manager) setup(ctx context.Context) (nat.NAT, *Mapping, error) {
if err != nil {
return nil, nil, fmt.Errorf("create port mapping: %w", err)
}
// Only meaningful once a mapping has been attempted: that is what opens the
// pinhole and records its outcome.
logIPv6Pinhole(gateway)
return gateway, mapping, nil
}
@@ -265,7 +268,9 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b
return false
}
pcpNAT, ok := gateway.(*pcp.NAT)
// Assert on the interface, not on a concrete type: a dual-stack gateway is
// a wrapper around the IPv4 NAT, so a type assertion misses it.
checker, ok := gateway.(nat.HealthChecker)
if !ok {
return false
}
@@ -273,7 +278,7 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
epoch, serverRestarted, err := pcpNAT.CheckServerHealth(ctx)
epoch, serverRestarted, err := checker.CheckServerHealth(ctx)
if err != nil {
log.Debugf("PCP health check failed: %v", err)
return false
@@ -340,3 +345,18 @@ func (m *Manager) startTearDown(ctx context.Context) {
func isPermanentLeaseRequired(err error) bool {
return err != nil && upnpErrPermanentLeaseOnly.MatchString(err.Error())
}
// logIPv6Pinhole reports the outcome of the IPv6 pinhole. Pinholes are best
// effort and never fail a mapping on their own, so this is the only way to see
// whether one was actually opened.
func logIPv6Pinhole(gateway nat.NAT) {
reporter, ok := gateway.(nat.IPv6PinholeReporter)
if !ok {
return
}
if err := reporter.IPv6PinholeError(); err != nil {
log.Warnf("IPv6 pinhole: %v", err)
return
}
log.Infof("IPv6 pinhole open")
}

View File

@@ -1,408 +0,0 @@
package pcp
import (
"context"
"crypto/rand"
"errors"
"fmt"
"net"
"net/netip"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
const (
defaultTimeout = 3 * time.Second
responseBufferSize = 128
// RFC 6887 Section 8.1.1 retry timing
initialRetryDelay = 3 * time.Second
maxRetryDelay = 1024 * time.Second
maxRetries = 4 // 3s + 6s + 12s + 24s = 45s total worst case
)
// Client is a PCP protocol client.
// All methods are safe for concurrent use.
type Client struct {
gateway netip.Addr
timeout time.Duration
mu sync.Mutex
// localIP caches the resolved local IP address.
localIP netip.Addr
// lastEpoch is the last observed server epoch value.
lastEpoch uint32
// epochTime tracks when lastEpoch was received for state loss detection.
epochTime time.Time
// externalIP caches the external IP from the last successful MAP response.
externalIP netip.Addr
// epochStateLost is set when epoch indicates server restart.
epochStateLost bool
}
// NewClient creates a new PCP client for the gateway at the given IP.
func NewClient(gateway net.IP) *Client {
addr, ok := netip.AddrFromSlice(gateway)
if !ok {
log.Debugf("invalid gateway IP: %v", gateway)
}
return &Client{
gateway: addr.Unmap(),
timeout: defaultTimeout,
}
}
// NewClientWithTimeout creates a new PCP client with a custom timeout.
func NewClientWithTimeout(gateway net.IP, timeout time.Duration) *Client {
addr, ok := netip.AddrFromSlice(gateway)
if !ok {
log.Debugf("invalid gateway IP: %v", gateway)
}
return &Client{
gateway: addr.Unmap(),
timeout: timeout,
}
}
// SetLocalIP sets the local IP address to use in PCP requests.
func (c *Client) SetLocalIP(ip net.IP) {
addr, ok := netip.AddrFromSlice(ip)
if !ok {
log.Debugf("invalid local IP: %v", ip)
}
c.mu.Lock()
c.localIP = addr.Unmap()
c.mu.Unlock()
}
// Gateway returns the gateway IP address.
func (c *Client) Gateway() net.IP {
return c.gateway.AsSlice()
}
// Announce sends a PCP ANNOUNCE request to discover PCP support.
// Returns the server's epoch time on success.
func (c *Client) Announce(ctx context.Context) (epoch uint32, err error) {
localIP, err := c.getLocalIP()
if err != nil {
return 0, fmt.Errorf("get local IP: %w", err)
}
req := buildAnnounceRequest(localIP)
resp, err := c.sendRequest(ctx, req)
if err != nil {
return 0, fmt.Errorf("send announce: %w", err)
}
parsed, err := parseResponse(resp)
if err != nil {
return 0, fmt.Errorf("parse announce response: %w", err)
}
if parsed.ResultCode != ResultSuccess {
return 0, fmt.Errorf("PCP ANNOUNCE failed: %s", ResultCodeString(parsed.ResultCode))
}
c.mu.Lock()
if c.updateEpochLocked(parsed.Epoch) {
log.Warnf("PCP server epoch indicates state loss - mappings may need refresh")
}
c.mu.Unlock()
return parsed.Epoch, nil
}
// AddPortMapping requests a port mapping from the PCP server.
func (c *Client) AddPortMapping(ctx context.Context, protocol string, internalPort int, lifetime time.Duration) (*MapResponse, error) {
return c.addPortMappingWithHint(ctx, protocol, internalPort, internalPort, netip.Addr{}, lifetime)
}
// AddPortMappingWithHint requests a port mapping with suggested external port and IP.
// Use lifetime <= 0 to delete a mapping.
func (c *Client) AddPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP net.IP, lifetime time.Duration) (*MapResponse, error) {
var extIP netip.Addr
if suggestedExtIP != nil {
var ok bool
extIP, ok = netip.AddrFromSlice(suggestedExtIP)
if !ok {
log.Debugf("invalid suggested external IP: %v", suggestedExtIP)
}
extIP = extIP.Unmap()
}
return c.addPortMappingWithHint(ctx, protocol, internalPort, suggestedExtPort, extIP, lifetime)
}
func (c *Client) addPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP netip.Addr, lifetime time.Duration) (*MapResponse, error) {
localIP, err := c.getLocalIP()
if err != nil {
return nil, fmt.Errorf("get local IP: %w", err)
}
proto, err := protocolNumber(protocol)
if err != nil {
return nil, fmt.Errorf("parse protocol: %w", err)
}
var nonce [12]byte
if _, err := rand.Read(nonce[:]); err != nil {
return nil, fmt.Errorf("generate nonce: %w", err)
}
// Convert lifetime to seconds. Lifetime 0 means delete, so only apply
// default for positive durations that round to 0 seconds.
var lifetimeSec uint32
if lifetime > 0 {
lifetimeSec = uint32(lifetime.Seconds())
if lifetimeSec == 0 {
lifetimeSec = DefaultLifetime
}
}
req := buildMapRequest(localIP, nonce, proto, uint16(internalPort), uint16(suggestedExtPort), suggestedExtIP, lifetimeSec)
resp, err := c.sendRequest(ctx, req)
if err != nil {
return nil, fmt.Errorf("send map request: %w", err)
}
mapResp, err := parseMapResponse(resp)
if err != nil {
return nil, fmt.Errorf("parse map response: %w", err)
}
if mapResp.Nonce != nonce {
return nil, fmt.Errorf("nonce mismatch in response")
}
if mapResp.Protocol != proto {
return nil, fmt.Errorf("protocol mismatch: requested %d, got %d", proto, mapResp.Protocol)
}
if mapResp.InternalPort != uint16(internalPort) {
return nil, fmt.Errorf("internal port mismatch: requested %d, got %d", internalPort, mapResp.InternalPort)
}
if mapResp.ResultCode != ResultSuccess {
return nil, &Error{
Code: mapResp.ResultCode,
Message: ResultCodeString(mapResp.ResultCode),
}
}
c.mu.Lock()
if c.updateEpochLocked(mapResp.Epoch) {
log.Warnf("PCP server epoch indicates state loss - mappings may need refresh")
}
c.cacheExternalIPLocked(mapResp.ExternalIP)
c.mu.Unlock()
return mapResp, nil
}
// DeletePortMapping removes a port mapping by requesting zero lifetime.
func (c *Client) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error {
if _, err := c.addPortMappingWithHint(ctx, protocol, internalPort, 0, netip.Addr{}, 0); err != nil {
var pcpErr *Error
if errors.As(err, &pcpErr) && pcpErr.Code == ResultNotAuthorized {
return nil
}
return fmt.Errorf("delete mapping: %w", err)
}
return nil
}
// GetExternalAddress returns the external IP address.
// First checks for a cached value from previous MAP responses.
// If not cached, creates a short-lived mapping to discover the external IP.
func (c *Client) GetExternalAddress(ctx context.Context) (net.IP, error) {
c.mu.Lock()
if c.externalIP.IsValid() {
ip := c.externalIP.AsSlice()
c.mu.Unlock()
return ip, nil
}
c.mu.Unlock()
// Use an ephemeral port in the dynamic range (49152-65535).
// Port 0 is not valid with UDP/TCP protocols per RFC 6887.
ephemeralPort := 49152 + int(uint16(time.Now().UnixNano()))%(65535-49152)
// Use minimal lifetime (1 second) for discovery.
resp, err := c.AddPortMapping(ctx, "udp", ephemeralPort, time.Second)
if err != nil {
return nil, fmt.Errorf("create temporary mapping: %w", err)
}
if err := c.DeletePortMapping(ctx, "udp", ephemeralPort); err != nil {
log.Debugf("cleanup temporary PCP mapping: %v", err)
}
return resp.ExternalIP.AsSlice(), nil
}
// LastEpoch returns the last observed server epoch value.
// A decrease in epoch indicates the server may have restarted and mappings may be lost.
func (c *Client) LastEpoch() uint32 {
c.mu.Lock()
defer c.mu.Unlock()
return c.lastEpoch
}
// EpochStateLost returns true if epoch state loss was detected and clears the flag.
func (c *Client) EpochStateLost() bool {
c.mu.Lock()
defer c.mu.Unlock()
lost := c.epochStateLost
c.epochStateLost = false
return lost
}
// updateEpoch updates the epoch tracking and detects potential state loss.
// Returns true if state loss was detected (server likely restarted).
// Caller must hold c.mu.
func (c *Client) updateEpochLocked(newEpoch uint32) bool {
now := time.Now()
stateLost := false
// RFC 6887 Section 8.5: Detect invalid epoch indicating server state loss.
// client_delta = time since last response
// server_delta = epoch change since last response
// Invalid if: client_delta+2 < server_delta - server_delta/16
// OR: server_delta+2 < client_delta - client_delta/16
// The +2 handles quantization, /16 (6.25%) handles clock drift.
if !c.epochTime.IsZero() && c.lastEpoch > 0 {
clientDelta := uint32(now.Sub(c.epochTime).Seconds())
serverDelta := newEpoch - c.lastEpoch
// Check for epoch going backwards or jumping unexpectedly.
// Subtraction is safe: serverDelta/16 is always <= serverDelta.
if clientDelta+2 < serverDelta-(serverDelta/16) ||
serverDelta+2 < clientDelta-(clientDelta/16) {
stateLost = true
c.epochStateLost = true
}
}
c.lastEpoch = newEpoch
c.epochTime = now
return stateLost
}
// cacheExternalIP stores the external IP from a successful MAP response.
// Caller must hold c.mu.
func (c *Client) cacheExternalIPLocked(ip netip.Addr) {
if ip.IsValid() && !ip.IsUnspecified() {
c.externalIP = ip
}
}
// sendRequest sends a PCP request with retries per RFC 6887 Section 8.1.1.
func (c *Client) sendRequest(ctx context.Context, req []byte) ([]byte, error) {
addr := &net.UDPAddr{IP: c.gateway.AsSlice(), Port: Port}
var lastErr error
delay := initialRetryDelay
for range maxRetries {
resp, err := c.sendOnce(ctx, addr, req)
if err == nil {
return resp, nil
}
lastErr = err
if ctx.Err() != nil {
return nil, ctx.Err()
}
// RFC 6887 Section 8.1.1: RT = (1 + RAND) * MIN(2 * RTprev, MRT)
// RAND is random between -0.1 and +0.1
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(retryDelayWithJitter(delay)):
}
delay = min(delay*2, maxRetryDelay)
}
return nil, fmt.Errorf("PCP request failed after %d retries: %w", maxRetries, lastErr)
}
// retryDelayWithJitter applies RFC 6887 jitter: multiply by (1 + RAND) where RAND is [-0.1, +0.1].
func retryDelayWithJitter(d time.Duration) time.Duration {
var b [1]byte
_, _ = rand.Read(b[:])
// Convert byte to range [-0.1, +0.1]: (b/255 * 0.2) - 0.1
jitter := (float64(b[0])/255.0)*0.2 - 0.1
return time.Duration(float64(d) * (1 + jitter))
}
func (c *Client) sendOnce(ctx context.Context, addr *net.UDPAddr, req []byte) ([]byte, error) {
// Use ListenUDP instead of DialUDP to validate response source address per RFC 6887 §8.3.
conn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, fmt.Errorf("listen: %w", err)
}
defer func() {
if err := conn.Close(); err != nil {
log.Debugf("close UDP connection: %v", err)
}
}()
timeout := c.timeout
if deadline, ok := ctx.Deadline(); ok {
if remaining := time.Until(deadline); remaining < timeout {
timeout = remaining
}
}
if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
return nil, fmt.Errorf("set deadline: %w", err)
}
if _, err := conn.WriteToUDP(req, addr); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
resp := make([]byte, responseBufferSize)
n, from, err := conn.ReadFromUDP(resp)
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
// RFC 6887 §8.3: Validate response came from expected PCP server.
if !from.IP.Equal(addr.IP) {
return nil, fmt.Errorf("response from unexpected source %s (expected %s)", from.IP, addr.IP)
}
return resp[:n], nil
}
func (c *Client) getLocalIP() (netip.Addr, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !c.localIP.IsValid() {
return netip.Addr{}, fmt.Errorf("local IP not set for gateway %s", c.gateway)
}
return c.localIP, nil
}
func protocolNumber(protocol string) (uint8, error) {
switch protocol {
case "udp", "UDP":
return ProtoUDP, nil
case "tcp", "TCP":
return ProtoTCP, nil
default:
return 0, fmt.Errorf("unsupported protocol: %s", protocol)
}
}
// Error represents a PCP error response.
type Error struct {
Code uint8
Message string
}
func (e *Error) Error() string {
return fmt.Sprintf("PCP error: %s (%d)", e.Message, e.Code)
}

View File

@@ -1,187 +0,0 @@
package pcp
import (
"context"
"net"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAddrConversion(t *testing.T) {
tests := []struct {
name string
addr netip.Addr
}{
{"IPv4", netip.MustParseAddr("192.168.1.100")},
{"IPv4 loopback", netip.MustParseAddr("127.0.0.1")},
{"IPv6", netip.MustParseAddr("2001:db8::1")},
{"IPv6 loopback", netip.MustParseAddr("::1")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b16 := addrTo16(tt.addr)
recovered := addrFrom16(b16)
assert.Equal(t, tt.addr, recovered, "address should round-trip")
})
}
}
func TestBuildAnnounceRequest(t *testing.T) {
clientIP := netip.MustParseAddr("192.168.1.100")
req := buildAnnounceRequest(clientIP)
require.Len(t, req, headerSize)
assert.Equal(t, byte(Version), req[0], "version")
assert.Equal(t, byte(OpAnnounce), req[1], "opcode")
// Check client IP is properly encoded as IPv4-mapped IPv6
assert.Equal(t, byte(0xff), req[18], "IPv4-mapped prefix byte 10")
assert.Equal(t, byte(0xff), req[19], "IPv4-mapped prefix byte 11")
assert.Equal(t, byte(192), req[20], "IP octet 1")
assert.Equal(t, byte(168), req[21], "IP octet 2")
assert.Equal(t, byte(1), req[22], "IP octet 3")
assert.Equal(t, byte(100), req[23], "IP octet 4")
}
func TestBuildMapRequest(t *testing.T) {
clientIP := netip.MustParseAddr("192.168.1.100")
nonce := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
req := buildMapRequest(clientIP, nonce, ProtoUDP, 51820, 51820, netip.Addr{}, 3600)
require.Len(t, req, mapRequestSize)
assert.Equal(t, byte(Version), req[0], "version")
assert.Equal(t, byte(OpMap), req[1], "opcode")
// Lifetime at bytes 4-7
assert.Equal(t, uint32(3600), (uint32(req[4])<<24)|(uint32(req[5])<<16)|(uint32(req[6])<<8)|uint32(req[7]), "lifetime")
// Nonce at bytes 24-35
assert.Equal(t, nonce[:], req[24:36], "nonce")
// Protocol at byte 36
assert.Equal(t, byte(ProtoUDP), req[36], "protocol")
// Internal port at bytes 40-41
assert.Equal(t, uint16(51820), (uint16(req[40])<<8)|uint16(req[41]), "internal port")
// External port at bytes 42-43
assert.Equal(t, uint16(51820), (uint16(req[42])<<8)|uint16(req[43]), "external port")
}
func TestParseResponse(t *testing.T) {
// Construct a valid ANNOUNCE response
resp := make([]byte, headerSize)
resp[0] = Version
resp[1] = OpAnnounce | OpReply
// Result code = 0 (success)
// Lifetime = 0
// Epoch = 12345
resp[8] = 0
resp[9] = 0
resp[10] = 0x30
resp[11] = 0x39
parsed, err := parseResponse(resp)
require.NoError(t, err)
assert.Equal(t, uint8(Version), parsed.Version)
assert.Equal(t, uint8(OpAnnounce|OpReply), parsed.Opcode)
assert.Equal(t, uint8(ResultSuccess), parsed.ResultCode)
assert.Equal(t, uint32(12345), parsed.Epoch)
}
func TestParseResponseErrors(t *testing.T) {
t.Run("too short", func(t *testing.T) {
_, err := parseResponse([]byte{1, 2, 3})
assert.Error(t, err)
})
t.Run("wrong version", func(t *testing.T) {
resp := make([]byte, headerSize)
resp[0] = 1 // Wrong version
resp[1] = OpReply
_, err := parseResponse(resp)
assert.Error(t, err)
})
t.Run("missing reply bit", func(t *testing.T) {
resp := make([]byte, headerSize)
resp[0] = Version
resp[1] = OpAnnounce // Missing OpReply bit
_, err := parseResponse(resp)
assert.Error(t, err)
})
}
func TestResultCodeString(t *testing.T) {
assert.Equal(t, "SUCCESS", ResultCodeString(ResultSuccess))
assert.Equal(t, "NOT_AUTHORIZED", ResultCodeString(ResultNotAuthorized))
assert.Equal(t, "ADDRESS_MISMATCH", ResultCodeString(ResultAddressMismatch))
assert.Contains(t, ResultCodeString(255), "UNKNOWN")
}
func TestProtocolNumber(t *testing.T) {
proto, err := protocolNumber("udp")
require.NoError(t, err)
assert.Equal(t, uint8(ProtoUDP), proto)
proto, err = protocolNumber("tcp")
require.NoError(t, err)
assert.Equal(t, uint8(ProtoTCP), proto)
proto, err = protocolNumber("UDP")
require.NoError(t, err)
assert.Equal(t, uint8(ProtoUDP), proto)
_, err = protocolNumber("icmp")
assert.Error(t, err)
}
func TestClientCreation(t *testing.T) {
gateway := netip.MustParseAddr("192.168.1.1").AsSlice()
client := NewClient(gateway)
assert.Equal(t, net.IP(gateway), client.Gateway())
assert.Equal(t, defaultTimeout, client.timeout)
clientWithTimeout := NewClientWithTimeout(gateway, 5*time.Second)
assert.Equal(t, 5*time.Second, clientWithTimeout.timeout)
}
func TestNATType(t *testing.T) {
n := NewNAT(netip.MustParseAddr("192.168.1.1").AsSlice(), netip.MustParseAddr("192.168.1.100").AsSlice())
assert.Equal(t, "PCP", n.Type())
}
// Integration test - skipped unless PCP_TEST_GATEWAY env is set
func TestClientIntegration(t *testing.T) {
t.Skip("Integration test - run manually with PCP_TEST_GATEWAY=<gateway-ip>")
gateway := netip.MustParseAddr("10.0.1.1").AsSlice() // Change to your test gateway
localIP := netip.MustParseAddr("10.0.1.100").AsSlice() // Change to your local IP
client := NewClient(gateway)
client.SetLocalIP(localIP)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Test ANNOUNCE
epoch, err := client.Announce(ctx)
require.NoError(t, err)
t.Logf("Server epoch: %d", epoch)
// Test MAP
resp, err := client.AddPortMapping(ctx, "udp", 51820, 1*time.Hour)
require.NoError(t, err)
t.Logf("Mapping: internal=%d external=%d externalIP=%s",
resp.InternalPort, resp.ExternalPort, resp.ExternalIP)
// Cleanup
err = client.DeletePortMapping(ctx, "udp", 51820)
require.NoError(t, err)
}

View File

@@ -1,222 +0,0 @@
package pcp
import (
"context"
"fmt"
"net"
"net/netip"
"runtime"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/libp2p/go-nat"
"github.com/libp2p/go-netroute"
)
var _ nat.NAT = (*NAT)(nil)
// NAT implements the go-nat NAT interface using PCP.
// Supports dual-stack (IPv4 and IPv6) when available.
// All methods are safe for concurrent use.
//
// TODO: IPv6 pinholes use the local IPv6 address. If the address changes
// (e.g., due to SLAAC rotation or network change), the pinhole becomes stale
// and needs to be recreated with the new address.
type NAT struct {
client *Client
mu sync.RWMutex
// client6 is the IPv6 PCP client, nil if IPv6 is unavailable.
client6 *Client
// localIP6 caches the local IPv6 address used for PCP requests.
localIP6 netip.Addr
}
// NewNAT creates a new NAT instance backed by PCP.
func NewNAT(gateway, localIP net.IP) *NAT {
client := NewClient(gateway)
client.SetLocalIP(localIP)
return &NAT{
client: client,
}
}
// Type returns "PCP" as the NAT type.
func (n *NAT) Type() string {
return "PCP"
}
// GetDeviceAddress returns the gateway IP address.
func (n *NAT) GetDeviceAddress() (net.IP, error) {
return n.client.Gateway(), nil
}
// GetExternalAddress returns the external IP address.
func (n *NAT) GetExternalAddress() (net.IP, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return n.client.GetExternalAddress(ctx)
}
// GetInternalAddress returns the local IP address used to communicate with the gateway.
func (n *NAT) GetInternalAddress() (net.IP, error) {
addr, err := n.client.getLocalIP()
if err != nil {
return nil, err
}
return addr.AsSlice(), nil
}
// AddPortMapping creates a port mapping on both IPv4 and IPv6 (if available).
func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) {
resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout)
if err != nil {
return 0, fmt.Errorf("add mapping: %w", err)
}
n.mu.RLock()
client6 := n.client6
localIP6 := n.localIP6
n.mu.RUnlock()
if client6 == nil {
return int(resp.ExternalPort), nil
}
if _, err := client6.AddPortMapping(ctx, protocol, internalPort, timeout); err != nil {
log.Warnf("IPv6 PCP mapping failed (continuing with IPv4): %v", err)
return int(resp.ExternalPort), nil
}
log.Infof("created IPv6 PCP pinhole: %s:%d", localIP6, internalPort)
return int(resp.ExternalPort), nil
}
// DeletePortMapping removes a port mapping from both IPv4 and IPv6.
func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error {
err := n.client.DeletePortMapping(ctx, protocol, internalPort)
n.mu.RLock()
client6 := n.client6
n.mu.RUnlock()
if client6 != nil {
if err6 := client6.DeletePortMapping(ctx, protocol, internalPort); err6 != nil {
log.Warnf("IPv6 PCP delete mapping failed: %v", err6)
}
}
if err != nil {
return fmt.Errorf("delete mapping: %w", err)
}
return nil
}
// CheckServerHealth sends an ANNOUNCE to verify the server is still responsive.
// Returns the current epoch and whether the server may have restarted (epoch state loss detected).
func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) {
epoch, err = n.client.Announce(ctx)
if err != nil {
return 0, false, fmt.Errorf("announce: %w", err)
}
return epoch, n.client.EpochStateLost(), nil
}
// DiscoverPCP attempts to discover a PCP-capable gateway.
// Returns a NAT interface if PCP is supported, or an error otherwise.
// Discovers both IPv4 and IPv6 gateways when available.
func DiscoverPCP(ctx context.Context) (nat.NAT, error) {
gateway, localIP, err := getDefaultGateway()
if err != nil {
return nil, fmt.Errorf("get default gateway: %w", err)
}
client := NewClient(gateway)
client.SetLocalIP(localIP)
if _, err := client.Announce(ctx); err != nil {
return nil, fmt.Errorf("PCP announce: %w", err)
}
result := &NAT{client: client}
discoverIPv6(ctx, result)
return result, nil
}
func discoverIPv6(ctx context.Context, result *NAT) {
gateway6, localIP6, err := getDefaultGateway6()
if err != nil {
log.Debugf("IPv6 gateway discovery failed: %v", err)
return
}
client6 := NewClient(gateway6)
client6.SetLocalIP(localIP6)
if _, err := client6.Announce(ctx); err != nil {
log.Debugf("PCP IPv6 announce failed: %v", err)
return
}
addr, ok := netip.AddrFromSlice(localIP6)
if !ok {
log.Debugf("invalid IPv6 local IP: %v", localIP6)
return
}
result.mu.Lock()
result.client6 = client6
result.localIP6 = addr
result.mu.Unlock()
log.Debugf("PCP IPv6 gateway discovered: %s (local: %s)", gateway6, localIP6)
}
// getDefaultGateway returns the default IPv4 gateway and local IP using the system routing table.
func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
router, err := netroute.New()
if err != nil {
return nil, nil, err
}
dst := net.IPv4zero
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
// go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
// TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties /
// NWPathMonitor) when netlink-based lookup is restricted or unavailable.
dst = net.IPv4(0, 0, 0, 1)
}
_, gateway, localIP, err = router.Route(dst)
if err != nil {
return nil, nil, err
}
if gateway == nil {
return nil, nil, nat.ErrNoNATFound
}
return gateway, localIP, nil
}
// getDefaultGateway6 returns the default IPv6 gateway IP address using the system routing table.
func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
router, err := netroute.New()
if err != nil {
return nil, nil, err
}
dst := net.IPv6zero
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
// ::2
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
}
_, gateway, localIP, err = router.Route(dst)
if err != nil {
return nil, nil, err
}
if gateway == nil {
return nil, nil, nat.ErrNoNATFound
}
return gateway, localIP, nil
}

View File

@@ -1,225 +0,0 @@
// Package pcp implements the Port Control Protocol (RFC 6887).
//
// # Implemented Features
//
// - ANNOUNCE opcode: Discovers PCP server support
// - MAP opcode: Creates/deletes port mappings (IPv4 NAT) and firewall pinholes (IPv6)
// - Dual-stack: Simultaneous IPv4 and IPv6 support via separate clients
// - Nonce validation: Prevents response spoofing
// - Epoch tracking: Detects server restarts per Section 8.5
// - RFC-compliant retry timing: 3s initial, exponential backoff to 1024s max (Section 8.1.1)
//
// # Not Implemented
//
// - PEER opcode: For outbound peer connections (not needed for inbound NAT traversal)
// - THIRD_PARTY option: For managing mappings on behalf of other devices
// - PREFER_FAILURE option: Requires exact external port or fail (IPv4 NAT only, not needed for IPv6 pinholing)
// - FILTER option: To restrict remote peer addresses
//
// These optional features are omitted because the primary use case is simple
// port forwarding for WireGuard, which only requires MAP with default behavior.
package pcp
import (
"encoding/binary"
"fmt"
"net/netip"
)
const (
// Version is the PCP protocol version (RFC 6887).
Version = 2
// Port is the standard PCP server port.
Port = 5351
// DefaultLifetime is the default requested mapping lifetime in seconds.
DefaultLifetime = 7200 // 2 hours
// Header sizes
headerSize = 24
mapPayloadSize = 36
mapRequestSize = headerSize + mapPayloadSize // 60 bytes
)
// Opcodes
const (
OpAnnounce = 0
OpMap = 1
OpPeer = 2
OpReply = 0x80 // OR'd with opcode in responses
)
// Protocol numbers for MAP requests
const (
ProtoUDP = 17
ProtoTCP = 6
)
// Result codes (RFC 6887 Section 7.4)
const (
ResultSuccess = 0
ResultUnsuppVersion = 1
ResultNotAuthorized = 2
ResultMalformedRequest = 3
ResultUnsuppOpcode = 4
ResultUnsuppOption = 5
ResultMalformedOption = 6
ResultNetworkFailure = 7
ResultNoResources = 8
ResultUnsuppProtocol = 9
ResultUserExQuota = 10
ResultCannotProvideExt = 11
ResultAddressMismatch = 12
ResultExcessiveRemotePeers = 13
)
// ResultCodeString returns a human-readable string for a result code.
func ResultCodeString(code uint8) string {
switch code {
case ResultSuccess:
return "SUCCESS"
case ResultUnsuppVersion:
return "UNSUPP_VERSION"
case ResultNotAuthorized:
return "NOT_AUTHORIZED"
case ResultMalformedRequest:
return "MALFORMED_REQUEST"
case ResultUnsuppOpcode:
return "UNSUPP_OPCODE"
case ResultUnsuppOption:
return "UNSUPP_OPTION"
case ResultMalformedOption:
return "MALFORMED_OPTION"
case ResultNetworkFailure:
return "NETWORK_FAILURE"
case ResultNoResources:
return "NO_RESOURCES"
case ResultUnsuppProtocol:
return "UNSUPP_PROTOCOL"
case ResultUserExQuota:
return "USER_EX_QUOTA"
case ResultCannotProvideExt:
return "CANNOT_PROVIDE_EXTERNAL"
case ResultAddressMismatch:
return "ADDRESS_MISMATCH"
case ResultExcessiveRemotePeers:
return "EXCESSIVE_REMOTE_PEERS"
default:
return fmt.Sprintf("UNKNOWN(%d)", code)
}
}
// Response represents a parsed PCP response header.
type Response struct {
Version uint8
Opcode uint8
ResultCode uint8
Lifetime uint32
Epoch uint32
}
// MapResponse contains the full response to a MAP request.
type MapResponse struct {
Response
Nonce [12]byte
Protocol uint8
InternalPort uint16
ExternalPort uint16
ExternalIP netip.Addr
}
// addrTo16 converts an address to its 16-byte IPv4-mapped IPv6 representation.
func addrTo16(addr netip.Addr) [16]byte {
if addr.Is4() {
return netip.AddrFrom4(addr.As4()).As16()
}
return addr.As16()
}
// addrFrom16 extracts an address from a 16-byte representation, unmapping IPv4.
func addrFrom16(b [16]byte) netip.Addr {
return netip.AddrFrom16(b).Unmap()
}
// buildAnnounceRequest creates a PCP ANNOUNCE request packet.
func buildAnnounceRequest(clientIP netip.Addr) []byte {
req := make([]byte, headerSize)
req[0] = Version
req[1] = OpAnnounce
mapped := addrTo16(clientIP)
copy(req[8:24], mapped[:])
return req
}
// buildMapRequest creates a PCP MAP request packet.
func buildMapRequest(clientIP netip.Addr, nonce [12]byte, protocol uint8, internalPort, suggestedExtPort uint16, suggestedExtIP netip.Addr, lifetime uint32) []byte {
req := make([]byte, mapRequestSize)
// Header
req[0] = Version
req[1] = OpMap
binary.BigEndian.PutUint32(req[4:8], lifetime)
mapped := addrTo16(clientIP)
copy(req[8:24], mapped[:])
// MAP payload
copy(req[24:36], nonce[:])
req[36] = protocol
binary.BigEndian.PutUint16(req[40:42], internalPort)
binary.BigEndian.PutUint16(req[42:44], suggestedExtPort)
if suggestedExtIP.IsValid() {
extMapped := addrTo16(suggestedExtIP)
copy(req[44:60], extMapped[:])
}
return req
}
// parseResponse parses the common PCP response header.
func parseResponse(data []byte) (*Response, error) {
if len(data) < headerSize {
return nil, fmt.Errorf("response too short: %d bytes", len(data))
}
resp := &Response{
Version: data[0],
Opcode: data[1],
ResultCode: data[3], // Byte 2 is reserved, byte 3 is result code (RFC 6887 §7.2)
Lifetime: binary.BigEndian.Uint32(data[4:8]),
Epoch: binary.BigEndian.Uint32(data[8:12]),
}
if resp.Version != Version {
return nil, fmt.Errorf("unsupported PCP version: %d", resp.Version)
}
if resp.Opcode&OpReply == 0 {
return nil, fmt.Errorf("response missing reply bit: opcode=0x%02x", resp.Opcode)
}
return resp, nil
}
// parseMapResponse parses a complete MAP response.
func parseMapResponse(data []byte) (*MapResponse, error) {
if len(data) < mapRequestSize {
return nil, fmt.Errorf("MAP response too short: %d bytes", len(data))
}
resp, err := parseResponse(data)
if err != nil {
return nil, fmt.Errorf("parse header: %w", err)
}
mapResp := &MapResponse{
Response: *resp,
Protocol: data[36],
InternalPort: binary.BigEndian.Uint16(data[40:42]),
ExternalPort: binary.BigEndian.Uint16(data[42:44]),
ExternalIP: addrFrom16([16]byte(data[44:60])),
}
copy(mapResp.Nonce[:], data[24:36])
return mapResp, nil
}

View File

@@ -0,0 +1,116 @@
//go:build !js
package portforward
import (
"context"
"errors"
"strings"
"testing"
"github.com/netbirdio/go-nat"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockPinholeNAT is a gateway that also reports an IPv6 pinhole outcome, the
// shape a dual-stack gateway has.
type mockPinholeNAT struct {
*mockNAT
pinholeErr error
}
func (m *mockPinholeNAT) IPv6PinholeError() error {
return m.pinholeErr
}
func TestSetupLogsPinholeOutcome(t *testing.T) {
pinholeErr := errors.New("pcp ipv6: NOT_AUTHORIZED")
tests := []struct {
name string
pinholeErr error
mappingErr error
wantLevel log.Level
wantText string
}{
{
name: "an open pinhole is reported",
wantLevel: log.InfoLevel,
wantText: "IPv6 pinhole open",
},
{
name: "a failed pinhole is reported without failing the mapping",
// The IPv4 mapping is what the caller asked for, so the pinhole
// failure surfaces only in the log.
pinholeErr: pinholeErr,
wantLevel: log.WarnLevel,
wantText: pinholeErr.Error(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gateway := &mockPinholeNAT{mockNAT: newMockNAT(), pinholeErr: tt.pinholeErr}
hook := stubGatewayDiscovery(t, gateway)
m := NewManager()
m.wgPort = 51820
_, mapping, err := m.setup(context.Background())
require.NoError(t, err)
require.NotNil(t, mapping)
entry := findEntry(hook, tt.wantText)
require.NotNil(t, entry, "no log entry mentioning %q", tt.wantText)
assert.Equal(t, tt.wantLevel, entry.Level)
})
}
t.Run("a failed mapping reports no pinhole outcome", func(t *testing.T) {
// Nothing opened the pinhole, so whatever it currently reports says
// nothing about this attempt.
gateway := &mockPinholeNAT{mockNAT: newMockNAT()}
gateway.addMappingErr = errors.New("gateway refused")
hook := stubGatewayDiscovery(t, gateway)
m := NewManager()
m.wgPort = 51820
_, _, err := m.setup(context.Background())
require.Error(t, err)
assert.Nil(t, findEntry(hook, "IPv6 pinhole"))
})
}
// stubGatewayDiscovery makes discovery return gateway and captures log output.
func stubGatewayDiscovery(t *testing.T, gateway nat.NAT) *test.Hook {
t.Helper()
orig := discoverGateway
discoverGateway = func(context.Context) (nat.NAT, error) { return gateway, nil }
t.Cleanup(func() { discoverGateway = orig })
hook := test.NewGlobal()
origLevel := log.GetLevel()
log.SetLevel(log.DebugLevel)
t.Cleanup(func() {
hook.Reset()
log.SetLevel(origLevel)
})
return hook
}
func findEntry(hook *test.Hook, substr string) *log.Entry {
for _, entry := range hook.AllEntries() {
if strings.Contains(entry.Message, substr) {
return entry
}
}
return nil
}

View File

@@ -4,27 +4,94 @@ package portforward
import (
"context"
"errors"
"fmt"
"time"
"github.com/libp2p/go-nat"
"github.com/netbirdio/go-nat"
"github.com/netbirdio/go-nat/pcp"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/portforward/pcp"
)
// discoverGateway is the function used for NAT gateway discovery.
// It can be replaced in tests to avoid real network operations.
// Tries PCP first, then falls back to NAT-PMP/UPnP.
var discoverGateway = defaultDiscoverGateway
func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) {
pcpGateway, err := pcp.DiscoverPCP(ctx)
if err == nil {
return pcpGateway, nil
}
log.Debugf("PCP discovery failed: %v, trying NAT-PMP/UPnP", err)
// pinholeDiscoveryTimeout is the slice of the discovery budget held back for
// the IPv6 pinhole probe.
//
// Sizing it is coarser than it looks: PCP retransmits on a 3s socket timeout
// and a 3s first backoff, so a second attempt needs about 9s. Anything from
// roughly 1s to 8s therefore buys exactly one attempt, and this only sets how
// long that attempt waits. A PCP server sits on the local link and answers in
// milliseconds, so 3s is margin rather than need, and the rest is left to
// gateway discovery, whose multicast SSDP search alone takes 5s. A probe lost
// to a dropped packet is retried by the next discovery round.
//
// It is a variable so tests can shorten it.
var pinholeDiscoveryTimeout = 3 * time.Second
return nat.DiscoverGateway(ctx)
// Discovery entry points, as variables so tests can drive the fallback without
// touching the network.
var (
discoverNATGateway = nat.DiscoverGateway
discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) {
pinhole, err := pcp.DiscoverPCP(ctx)
if err != nil {
return nil, err
}
return pinhole, nil
}
)
// defaultDiscoverGateway finds a gateway that can make the WireGuard port
// reachable. DiscoverGateway prefers PCP for IPv4, races UPnP and NAT-PMP
// behind it, and attaches an IPv6 pinhole independently of which IPv4 protocol
// wins.
//
// It reports no gateway on a network offering only IPv6, having no IPv4 mapping
// to attach a pinhole to. Such a network still needs one: there is no
// translation to traverse, but the router drops inbound IPv6 until something
// opens it. Fall back to PCP alone, which yields a gateway holding just the
// pinhole.
func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) {
gatewayCtx, cancel := reserveForPinhole(ctx)
defer cancel()
gateway, err := discoverNATGateway(gatewayCtx)
if err == nil {
return gateway, nil
}
if !errors.Is(err, nat.ErrNoNATFound) {
return nil, err
}
pinhole, pinholeErr := discoverPCPPinhole(ctx)
if pinholeErr != nil {
log.Debugf("no IPv6 pinhole after %v: %v", err, pinholeErr)
return nil, err
}
log.Infof("no IPv4 gateway, continuing with an IPv6 pinhole only")
return pinhole, nil
}
// reserveForPinhole shortens ctx so that a pinhole probe still has time to run
// afterwards. Finding nothing takes gateway discovery everything it is given,
// so on the unshortened context the probe would start already expired. A budget
// too small to divide is left to gateway discovery, which is the likelier win.
func reserveForPinhole(ctx context.Context) (context.Context, context.CancelFunc) {
deadline, ok := ctx.Deadline()
if !ok {
return context.WithCancel(ctx)
}
remaining := time.Until(deadline)
if remaining <= pinholeDiscoveryTimeout {
return context.WithCancel(ctx)
}
return context.WithTimeout(ctx, remaining-pinholeDiscoveryTimeout)
}
// State is persisted only for crash recovery cleanup

View File

@@ -0,0 +1,140 @@
//go:build !js
package portforward
import (
"context"
"errors"
"testing"
"time"
"github.com/netbirdio/go-nat"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubDiscovery replaces both discovery entry points for the duration of a
// test. gatewayDelay simulates gateway discovery spending everything it is
// given before reporting that it found nothing.
func stubDiscovery(t *testing.T, gateway nat.NAT, gatewayErr error, gatewayDelay time.Duration, pinhole nat.NAT, pinholeErr error) {
t.Helper()
origGateway, origPinhole := discoverNATGateway, discoverPCPPinhole
discoverNATGateway = func(ctx context.Context) (nat.NAT, error) {
if gatewayDelay > 0 {
select {
case <-time.After(gatewayDelay):
case <-ctx.Done():
}
}
return gateway, gatewayErr
}
discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return pinhole, pinholeErr
}
t.Cleanup(func() { discoverNATGateway, discoverPCPPinhole = origGateway, origPinhole })
}
func TestDefaultDiscoverGateway(t *testing.T) {
ipv4Gateway := &mockNAT{natType: "PCP+PCPv6"}
ipv6Pinhole := &mockNAT{natType: "PCP"}
otherErr := errors.New("routing table unavailable")
t.Run("an IPv4 gateway is used as is", func(t *testing.T) {
stubDiscovery(t, ipv4Gateway, nil, 0, ipv6Pinhole, nil)
got, err := defaultDiscoverGateway(context.Background())
require.NoError(t, err)
assert.Same(t, ipv4Gateway, got)
})
t.Run("no IPv4 gateway still opens an IPv6 pinhole", func(t *testing.T) {
stubDiscovery(t, nil, nat.ErrNoNATFound, 0, ipv6Pinhole, nil)
got, err := defaultDiscoverGateway(context.Background())
require.NoError(t, err)
assert.Same(t, ipv6Pinhole, got)
})
t.Run("no gateway and no pinhole reports the original failure", func(t *testing.T) {
stubDiscovery(t, nil, nat.ErrNoNATFound, 0, nil, errors.New("no IPv6 route"))
got, err := defaultDiscoverGateway(context.Background())
assert.Nil(t, got)
assert.ErrorIs(t, err, nat.ErrNoNATFound, "the pinhole failure must not mask why no gateway was found")
})
t.Run("a failure other than no-gateway is reported as is", func(t *testing.T) {
stubDiscovery(t, nil, otherErr, 0, ipv6Pinhole, nil)
got, err := defaultDiscoverGateway(context.Background())
assert.Nil(t, got)
assert.ErrorIs(t, err, otherErr)
})
t.Run("the pinhole survives gateway discovery using its whole budget", func(t *testing.T) {
// On one shared context the probe would start already expired, which is
// how this failed against a real gateway.
reserve := 50 * time.Millisecond
origReserve := pinholeDiscoveryTimeout
pinholeDiscoveryTimeout = reserve
t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve })
budget := 4 * reserve
ctx, cancel := context.WithTimeout(context.Background(), budget)
defer cancel()
stubDiscovery(t, nil, nat.ErrNoNATFound, budget, ipv6Pinhole, nil)
got, err := defaultDiscoverGateway(ctx)
require.NoError(t, err)
assert.Same(t, ipv6Pinhole, got)
})
}
func TestReserveForPinhole(t *testing.T) {
origReserve := pinholeDiscoveryTimeout
pinholeDiscoveryTimeout = time.Second
t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve })
t.Run("a budget is divided", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
gatewayCtx, cancelGateway := reserveForPinhole(ctx)
defer cancelGateway()
deadline, ok := gatewayCtx.Deadline()
require.True(t, ok)
assert.InDelta(t, 9*time.Second, time.Until(deadline), float64(500*time.Millisecond))
})
t.Run("a budget too small to divide is left whole", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
gatewayCtx, cancelGateway := reserveForPinhole(ctx)
defer cancelGateway()
deadline, ok := gatewayCtx.Deadline()
require.True(t, ok)
assert.InDelta(t, 500*time.Millisecond, time.Until(deadline), float64(100*time.Millisecond))
})
t.Run("no deadline stays unbounded", func(t *testing.T) {
gatewayCtx, cancelGateway := reserveForPinhole(context.Background())
defer cancelGateway()
_, ok := gatewayCtx.Deadline()
assert.False(t, ok)
})
}

View File

@@ -103,6 +103,9 @@ type ConfigInput struct {
DNSLabels domain.List
MTU *uint16
LocalMetricsEnabled *bool
LocalMetricsAddress *string
}
// Config Configuration type
@@ -144,6 +147,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
@@ -388,6 +396,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
@@ -718,6 +738,12 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
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

View File

@@ -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

View File

@@ -109,6 +109,10 @@
// - Does NOT remove result.json (cleaned by ResultHandler after read)
// - Does NOT remove msi.log (kept for debugging)
//
// On Windows the updater copy is often still locked when the daemon it restarted
// runs cleanup, so removing it is retried briefly and otherwise left in place for
// the next update to overwrite rather than reported as a failure.
//
// # Dry-Run Mode
//
// Dry-run mode allows testing the update process without actually installing:

View File

@@ -0,0 +1,67 @@
package installer
import (
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/sys/windows"
)
// lockFile opens path without FILE_SHARE_DELETE, so os.Remove fails the way it does
// while the updater process still holds its own image.
func lockFile(t *testing.T, path string) windows.Handle {
t.Helper()
p, err := windows.UTF16PtrFromString(path)
if err != nil {
t.Fatalf("convert path: %v", err)
}
handle, err := windows.CreateFile(p, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
t.Fatalf("lock %s: %v", path, err)
}
return handle
}
// releaseAfter closes the handle once the delay has passed, standing in for the
// updater process finally exiting.
func releaseAfter(t *testing.T, handle windows.Handle, delay time.Duration) {
t.Helper()
released := make(chan struct{})
t.Cleanup(func() { <-released })
go func() {
defer close(released)
time.Sleep(delay)
if err := windows.CloseHandle(handle); err != nil {
t.Errorf("close handle: %v", err)
}
}()
}
// TestCleanUpInstallerFilesLockedUpdater covers the post-update cleanup race: the
// daemon cleans up at startup while the updater that restarted it is still exiting,
// so the updater image is locked and Windows refuses the delete. Cleanup must wait
// the lock out instead of reporting a failure and leaving the binary behind.
func TestCleanUpInstallerFilesLockedUpdater(t *testing.T) {
tempDir := t.TempDir()
path := filepath.Join(tempDir, updaterBinary)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatalf("write updater: %v", err)
}
releaseAfter(t, lockFile(t, path), 300*time.Millisecond)
u := NewWithDir(tempDir)
if err := u.CleanUpInstallerFiles(); err != nil {
t.Fatalf("cleanup must tolerate a still-locked updater: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("updater binary still present (stat err: %v)", err)
}
}

View File

@@ -152,8 +152,8 @@ func (u *Installer) CleanUpInstallerFiles() error {
var merr *multierror.Error
if err := os.Remove(filepath.Join(u.tempDir, updaterBinary)); err != nil && !os.IsNotExist(err) {
merr = multierror.Append(merr, fmt.Errorf("failed to remove updater binary: %w", err))
if err := removeUpdaterBinary(filepath.Join(u.tempDir, updaterBinary)); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove updater binary: %w", err))
}
entries, err := os.ReadDir(u.tempDir)
@@ -167,10 +167,16 @@ func (u *Installer) CleanUpInstallerFiles() error {
}
name := entry.Name()
// The updater copy is handled above; on Windows its name also matches the
// extension sweep, which would report the same file twice.
if strings.EqualFold(name, updaterBinary) {
continue
}
for _, ext := range binaryExtensions {
if strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) {
if err := os.Remove(filepath.Join(u.tempDir, name)); err != nil {
merr = multierror.Append(merr, fmt.Errorf("failed to remove %s: %w", name, err))
merr = multierror.Append(merr, fmt.Errorf("remove %s: %w", name, err))
}
break
}

View File

@@ -0,0 +1,52 @@
//go:build windows || darwin
package installer
import (
"os"
"path/filepath"
"testing"
)
// TestCleanUpInstallerFiles checks that cleanup removes the updater copy and the
// downloaded installer while leaving the logs and the result file for the daemon.
func TestCleanUpInstallerFiles(t *testing.T) {
tempDir := t.TempDir()
installers := make([]string, 0, len(binaryExtensions))
for _, ext := range binaryExtensions {
installers = append(installers, "netbird_installer."+ext)
}
kept := []string{"installer.log", "result.json"}
for _, name := range append(append([]string{updaterBinary}, installers...), kept...) {
if err := os.WriteFile(filepath.Join(tempDir, name), []byte("x"), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
u := NewWithDir(tempDir)
if err := u.CleanUpInstallerFiles(); err != nil {
t.Fatalf("CleanUpInstallerFiles: %v", err)
}
for _, name := range append([]string{updaterBinary}, installers...) {
if _, err := os.Stat(filepath.Join(tempDir, name)); !os.IsNotExist(err) {
t.Errorf("%s was not removed (stat err: %v)", name, err)
}
}
for _, name := range kept {
if _, err := os.Stat(filepath.Join(tempDir, name)); err != nil {
t.Errorf("%s should have been kept: %v", name, err)
}
}
}
func TestCleanUpInstallerFilesMissingTempDir(t *testing.T) {
u := NewWithDir(filepath.Join(t.TempDir(), "does-not-exist"))
if err := u.CleanUpInstallerFiles(); err != nil {
t.Errorf("a missing temp dir is not a cleanup failure, got: %v", err)
}
}

View File

@@ -0,0 +1,12 @@
package installer
import "os"
// removeUpdaterBinary deletes the updater copy left in the temp dir. On darwin a
// running binary can be unlinked, so no retry is needed.
func removeUpdaterBinary(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View File

@@ -0,0 +1,45 @@
package installer
import (
"errors"
"os"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
const (
// The updater is the process that restarted the daemon, so when the daemon
// cleans up at startup the updater is often still exiting and Windows refuses
// to delete its locked image. These bound how long cleanup waits for it.
updaterRemoveAttempts = 5
updaterRemoveDelay = 200 * time.Millisecond
)
// removeUpdaterBinary deletes the updater copy left in the temp dir, retrying
// while the still-exiting updater process holds its image. A binary that stays
// locked for the whole window is left in place and reported at info level: the
// next update overwrites it, so it is not worth failing cleanup over.
func removeUpdaterBinary(path string) error {
for attempt := 0; attempt < updaterRemoveAttempts; attempt++ {
if attempt > 0 {
time.Sleep(updaterRemoveDelay)
}
err := os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
if !isFileLocked(err) {
return err
}
}
log.Infof("updater binary %s is still locked, leaving it for the next update to overwrite", path)
return nil
}
func isFileLocked(err error) bool {
return errors.Is(err, windows.ERROR_ACCESS_DENIED) || errors.Is(err, windows.ERROR_SHARING_VIOLATION)
}

View File

@@ -0,0 +1,59 @@
package installer
import (
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestRemoveUpdaterBinaryRetriesWhileLocked(t *testing.T) {
path := filepath.Join(t.TempDir(), updaterBinary)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatalf("write updater: %v", err)
}
releaseAfter(t, lockFile(t, path), updaterRemoveDelay+50*time.Millisecond)
if err := removeUpdaterBinary(path); err != nil {
t.Fatalf("removeUpdaterBinary: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("updater binary still present (stat err: %v)", err)
}
}
// TestRemoveUpdaterBinaryStaysLocked covers an updater that never releases its
// image within the retry window. Cleanup gives up quietly and leaves the file
// behind rather than reporting a failure.
func TestRemoveUpdaterBinaryStaysLocked(t *testing.T) {
path := filepath.Join(t.TempDir(), updaterBinary)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatalf("write updater: %v", err)
}
handle := lockFile(t, path)
t.Cleanup(func() {
if err := windows.CloseHandle(handle); err != nil {
t.Errorf("close handle: %v", err)
}
})
if err := removeUpdaterBinary(path); err != nil {
t.Fatalf("a permanently locked updater is not a cleanup failure, got: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("locked updater binary should be left in place, stat: %v", err)
}
}
func TestRemoveUpdaterBinaryMissingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), updaterBinary)
if err := removeUpdaterBinary(path); err != nil {
t.Errorf("a missing updater binary is not a failure, got: %v", err)
}
}

View File

@@ -4,12 +4,14 @@ package NetBirdSDK
import (
"context"
"errors"
"fmt"
"net/netip"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
log "github.com/sirupsen/logrus"
@@ -22,8 +24,7 @@ import (
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"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"
@@ -38,6 +39,8 @@ const (
AnonymizeLevelStrict = nbAnonymize.LevelStrictString
)
var errClientAlreadyRunning = errors.New("client is already running")
// RouteListener export internal RouteListener for mobile
type NetworkChangeListener interface {
listener.NetworkChangeListener
@@ -75,31 +78,35 @@ type Client struct {
cacheDir string
logFilePath string
recorder *peer.Status
ctxCancel context.CancelFunc
ctxCancelLock *sync.Mutex
deviceName string
osName string
osVersion string
networkChangeListener listener.NetworkChangeListener
onHostDnsFn func([]string)
dnsManager dns.IosDnsManager
loginComplete bool
// netState outlives engine restarts: it mirrors the OS connectivity, not
// the engine lifecycle. Run injects 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
loginComplete atomic.Bool
// netMgr outlives engine restarts: it mirrors the OS connectivity, not
// the engine lifecycle. Run injects its state and sweeper into each new
// ConnectClient.
netMgr *netevents.Manager
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
preloadedConfig *profilemanager.Config
// stateMu guards the run lifecycle as one unit: the cancel installed by
// the current run, the channel it closes on exit, and the state it
// published. One run at a time: startRun refuses a second Run while the
// previous one has not exited, and the platform serializes Stop before
// Start, so no generation tracking is needed.
stateMu sync.RWMutex
connectClient *internal.ConnectClient
config *profilemanager.Config
runDone chan struct{}
ctxCancel context.CancelFunc
}
// NewClient instantiate a new Client
func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client {
recorder := peer.NewRecorder("")
return &Client{
cfgFile: cfgFile,
stateFile: stateFile,
@@ -108,12 +115,10 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
deviceName: deviceName,
osName: osName,
osVersion: osVersion,
recorder: peer.NewRecorder(""),
ctxCancelLock: &sync.Mutex{},
recorder: recorder,
networkChangeListener: networkChangeListener,
dnsManager: dnsManager,
netState: netstate.New(),
sweeper: netsweep.New(),
netMgr: netevents.NewManager(recorder),
}
}
@@ -159,17 +164,21 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
var ctx context.Context
//nolint
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
c.ctxCancelLock.Lock()
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
defer c.ctxCancel()
c.ctxCancelLock.Unlock()
runCtx, runCancel := context.WithCancel(ctxWithValues)
defer runCancel()
done, err := c.startRun(runCancel)
if err != nil {
return err
}
defer c.finishRun(done)
ctx := runCtx
// No login pre-flight here. The engine's own loginToManagement (connect.go) performs
// the authoritative Login immediately before the first Sync, so a LoginSync() call at
@@ -190,7 +199,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
cfg.WgIface = interfaceName
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper))
internal.WithNetEvents(c.netMgr))
c.setState(cfg, connectClient)
// Persist the latest sync response so DebugBundle can include the network
// map. On iOS this is backed by disk to keep it out of the constrained
@@ -203,10 +212,11 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
// (e.g. from NWPathMonitor). 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.
// immediately with a fresh backoff. Losing the last network also sweeps the
// registered connections, so the client does not keep reporting Connected
// over stale sockets 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
@@ -214,20 +224,43 @@ 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()
}
// Stop the internal client and free the resources
// Stop cancels the running client and waits for the run loop to exit, so a
// caller that restarts immediately cannot race the outgoing teardown.
func (c *Client) Stop() {
c.ctxCancelLock.Lock()
defer c.ctxCancelLock.Unlock()
if c.ctxCancel == nil {
done := c.cancelRun()
if done == nil {
return
}
c.ctxCancel()
c.setState(nil, nil)
select {
case <-done:
case <-time.After(stopRunWaitTimeout):
log.Warnf("Stop: timed out waiting for the run loop to exit")
}
}
// StopWithoutWait cancels the running client without waiting for the run loop.
// Use it where the caller is on a deadline the wait could overrun, such as
// NEPacketTunnelProvider.stopTunnel, which iOS gives only a few seconds
// before it kills the extension.
func (c *Client) StopWithoutWait() {
c.cancelRun()
}
func (c *Client) cancelRun() chan struct{} {
c.stateMu.RLock()
done := c.runDone
cancel := c.ctxCancel
c.stateMu.RUnlock()
if cancel != nil {
cancel()
}
return done
}
// DebugBundle generates a debug bundle, uploads it and returns the upload key.
@@ -379,16 +412,14 @@ func (c *Client) IsLoginRequiredCached() bool {
}
func (c *Client) IsLoginRequired() bool {
var ctx context.Context
//nolint
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
c.ctxCancelLock.Lock()
defer c.ctxCancelLock.Unlock()
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
ctx, cancel := context.WithCancel(ctxWithValues)
defer cancel()
var cfg *profilemanager.Config
var err error
@@ -436,17 +467,22 @@ func (c *Client) IsLoginRequired() bool {
// loginForMobileAuthTimeout is the timeout for requesting auth info from the server
const loginForMobileAuthTimeout = 30 * time.Second
const stopRunWaitTimeout = 20 * time.Second
func (c *Client) LoginForMobile() string {
var ctx context.Context
//nolint
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsNameCtxKey, c.osName)
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.OsVersionCtxKey, c.osVersion)
c.ctxCancelLock.Lock()
defer c.ctxCancelLock.Unlock()
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
ctx, cancel := context.WithCancel(ctxWithValues)
loginDone := false
defer func() {
if !loginDone {
cancel()
}
}()
// Use DirectUpdateOrCreateConfig to avoid atomic file operations (temp file + rename)
// which are blocked by the tvOS sandbox in App Group containers
@@ -473,7 +509,9 @@ func (c *Client) LoginForMobile() string {
}
// This could cause a potential race condition with loading the extension which need to be handled on swift side
loginDone = true
go func() {
defer cancel()
tokenInfo, err := oAuthFlow.WaitToken(ctx, flowInfo)
if err != nil {
log.Errorf("LoginForMobile: WaitToken failed: %v", err)
@@ -490,18 +528,18 @@ func (c *Client) LoginForMobile() string {
log.Errorf("LoginForMobile: Login failed: %v", err)
return
}
c.loginComplete = true
c.loginComplete.Store(true)
}()
return flowInfo.VerificationURIComplete
}
func (c *Client) IsLoginComplete() bool {
return c.loginComplete
return c.loginComplete.Load()
}
func (c *Client) ClearLoginComplete() {
c.loginComplete = false
c.loginComplete.Store(false)
}
func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
@@ -721,13 +759,36 @@ func (c *Client) DeselectRoute(id string) error {
return nil
}
// setState stores the running engine state so DebugBundle can reuse the live
// config and ConnectClient. It is cleared on Stop.
func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
func (c *Client) startRun(cancel context.CancelFunc) (chan struct{}, error) {
c.stateMu.Lock()
defer c.stateMu.Unlock()
if c.runDone != nil {
return nil, errClientAlreadyRunning
}
done := make(chan struct{})
c.runDone = done
c.ctxCancel = cancel
return done, nil
}
func (c *Client) finishRun(done chan struct{}) {
c.stateMu.Lock()
c.connectClient = nil
c.config = nil
c.runDone = nil
c.ctxCancel = nil
c.stateMu.Unlock()
close(done)
}
func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
c.stateMu.Lock()
c.config = cfg
c.connectClient = cc
c.stateMu.Unlock()
}
// stateSnapshot returns the current config and ConnectClient under the lock.

View File

@@ -0,0 +1,138 @@
//go:build ios
package NetBirdSDK
import (
"github.com/netbirdio/netbird/client/mobile"
)
const (
// iOS uses a single user context per app.
iosUsername = "ios"
)
// Profile represents a profile for gomobile.
type Profile struct {
ID string
Name string
Email string
IsActive bool
}
// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot
// bind Go slices directly).
type ProfileArray struct {
items []*Profile
}
// Length returns the number of profiles.
func (p *ProfileArray) Length() int {
return len(p.items)
}
// 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
}
return p.items[i]
}
// ProfileManager adapts the shared mobile profile manager (client/mobile) to
// gomobile-friendly types. See that package for the on-disk layout and
// semantics.
type ProfileManager struct {
impl *mobile.ProfileManager
}
// NewProfileManager creates a new profile manager for iOS. configDir is the
// App Group shared container path that both the app and the network extension
// can reach.
func NewProfileManager(configDir string) *ProfileManager {
return &ProfileManager{impl: mobile.NewProfileManager(configDir, iosUsername)}
}
// ListProfiles returns all available profiles, including the default profile,
// with their active status set.
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
profiles, err := pm.impl.ListProfiles()
if err != nil {
return nil, err
}
items := make([]*Profile, 0, len(profiles))
for i := range profiles {
items = append(items, fromMobileProfile(&profiles[i]))
}
return &ProfileArray{items: items}, nil
}
// GetActiveProfile returns the currently active profile.
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
p, err := pm.impl.GetActiveProfile()
if err != nil {
return nil, err
}
return fromMobileProfile(p), nil
}
// 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 {
return pm.impl.SwitchProfile(id)
}
// AddProfile creates a new profile with the given display name and a
// generated ID. It returns the created profile so the caller learns the ID.
func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
p, err := pm.impl.AddProfile(displayName)
if err != nil {
return nil, err
}
return fromMobileProfile(p), nil
}
// 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 {
return pm.impl.RenameProfile(id, newName)
}
// 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 {
return pm.impl.RemoveProfile(id)
}
// GetConfigPath returns the config file path for the given profile ID. Swift
// should call this instead of constructing paths itself.
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
return pm.impl.GetConfigPath(id)
}
// GetStateFilePath returns the state file path for the given profile ID.
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
return pm.impl.GetStateFilePath(id)
}
// GetActiveConfigPath returns the config file path for the currently active
// profile.
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
return pm.impl.GetActiveConfigPath()
}
// GetActiveStateFilePath returns the state file path for the currently active
// profile.
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
return pm.impl.GetActiveStateFilePath()
}
func fromMobileProfile(p *mobile.Profile) *Profile {
return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive}
}

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