Merge branch 'main' into embedded-vnc

This commit is contained in:
Viktor Liu
2026-08-27 16:01:14 +02:00
482 changed files with 34604 additions and 6503 deletions
+33
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
+6 -3
View File
@@ -730,6 +730,11 @@ jobs:
- name: Install modules - name: Install modules
run: go mod tidy run: go mod tidy
- name: Run Mage
uses: magefile/mage-action@a662bd8c29d8106879588cfff83b2faf6e6f59db # v4.0.0
with:
install-only: true
- name: check git status - name: check git status
run: git --no-pager diff --exit-code run: git --no-pager diff --exit-code
@@ -738,9 +743,7 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \ CI=true \
go test -tags=integration -coverprofile=coverage.txt \ mage integrationtest:all -gotestflags="-coverprofile=coverage.txt"
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
-timeout 20m ./management/server/http/...
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64' if: matrix.arch == 'amd64'
+13
View File
@@ -37,3 +37,16 @@ jobs:
repo: netbirdio/ios-client repo: netbirdio/ios-client
token: ${{ secrets.NC_GITHUB_TOKEN }} token: ${{ secrets.NC_GITHUB_TOKEN }}
inputs: '{ "tag": "${{ github.ref_name }}" }' inputs: '{ "tag": "${{ github.ref_name }}" }'
trigger_dashboard_bump:
runs-on: ubuntu-latest
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger dashboard wasm client bump
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
repo: netbirdio/dashboard
token: ${{ secrets.NC_GITHUB_TOKEN }}
inputs: '{ "tag": "${{ github.ref_name }}" }'
+10
View File
@@ -92,6 +92,11 @@ nfpms:
dst: /usr/share/applications/org.wails.netbird.desktop dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png - src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png dst: /usr/share/pixmaps/netbird.png
# Names the polkit action for the elevation prompt the app raises when an
# unprivileged user changes a privileged setting; without it the dialog
# shows a raw command line.
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
dependencies: dependencies:
- netbird (>= 0.75.0) - netbird (>= 0.75.0)
- libgtk-4-1 (>= 4.14) - libgtk-4-1 (>= 4.14)
@@ -116,6 +121,11 @@ nfpms:
dst: /usr/share/applications/org.wails.netbird.desktop dst: /usr/share/applications/org.wails.netbird.desktop
- src: client/ui/build/appicon.png - src: client/ui/build/appicon.png
dst: /usr/share/pixmaps/netbird.png dst: /usr/share/pixmaps/netbird.png
# Names the polkit action for the elevation prompt the app raises when an
# unprivileged user changes a privileged setting; without it the dialog
# shows a raw command line.
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
dependencies: dependencies:
- netbird >= 0.75.0 - netbird >= 0.75.0
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14) - (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
+17 -18
View File
@@ -26,8 +26,7 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager" "github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/client/netevents"
"github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/route"
@@ -82,13 +81,10 @@ type Client struct {
deviceName string deviceName string
uiVersion string uiVersion string
networkChangeListener listener.NetworkChangeListener networkChangeListener listener.NetworkChangeListener
// netState outlives engine restarts: it mirrors the OS connectivity, not // netMgr outlives engine restarts: it mirrors the OS connectivity, not
// the engine lifecycle. Run and RunWithoutLogin inject it into each new // the engine lifecycle. Run and RunWithoutLogin inject its state and
// ConnectClient, which distributes it to every reconnection loop. // sweeper into each new ConnectClient.
netState *netstate.State netMgr *netevents.Manager
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
sweeper *netsweep.Sweeper
stateMu sync.RWMutex stateMu sync.RWMutex
connectClient *internal.ConnectClient connectClient *internal.ConnectClient
@@ -153,16 +149,16 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket) net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
system.SetIFaceDiscover(iFaceDiscover) system.SetIFaceDiscover(iFaceDiscover)
recorder := peer.NewRecorder("")
return &Client{ return &Client{
deviceName: deviceName, deviceName: deviceName,
uiVersion: uiVersion, uiVersion: uiVersion,
tunAdapter: tunAdapter, tunAdapter: tunAdapter,
iFaceDiscover: iFaceDiscover, iFaceDiscover: iFaceDiscover,
recorder: peer.NewRecorder(""), recorder: recorder,
ctxCancelLock: &sync.Mutex{}, ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener, networkChangeListener: networkChangeListener,
netState: netstate.New(), netMgr: netevents.NewManager(recorder),
sweeper: netsweep.New(),
} }
} }
@@ -203,8 +199,9 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
} }
// todo do not throw error in case of cancelled context // todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx) ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) internal.WithNetEvents(c.netMgr))
c.setState(cfg, cacheDir, cfgFile, connectClient) c.setState(cfg, cacheDir, cfgFile, connectClient)
// This path runs the interactive SSO flow, so reaching here means the peer // This path runs the interactive SSO flow, so reaching here means the peer
// is authenticated again — release the latch Status() reports from. Clear // is authenticated again — release the latch Status() reports from. Clear
@@ -246,7 +243,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
// todo do not throw error in case of cancelled context // todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx) ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) internal.WithNetEvents(c.netMgr))
c.setState(cfg, cacheDir, cfgFile, connectClient) c.setState(cfg, cacheDir, cfgFile, connectClient)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
} }
@@ -298,9 +295,12 @@ func (c *Client) GetTunSettings() (*TunSettings, error) {
// While unavailable, the internal reconnect loops suspend their attempts and // While unavailable, the internal reconnect loops suspend their attempts and
// the connection listener reports NoNetwork instead of Connecting; when // the connection listener reports NoNetwork instead of Connecting; when
// availability returns, the loops resume immediately with a fresh backoff. // 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) { func (c *Client) SetNetworkAvailable(available bool) {
c.netState.Set(available) c.netMgr.SetNetworkAvailable(available)
c.recorder.SetNetworkAvailable(available)
} }
// NotifyNetworkChange marks the management, signal and relay connections // 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 // whatever has not redialed on the new network by then. The engine and the
// TUN device stay untouched. // TUN device stay untouched.
func (c *Client) NotifyNetworkChange() { func (c *Client) NotifyNetworkChange() {
c.sweeper.MarkNetworkChange() c.netMgr.NotifyNetworkChange()
log.Infof("network change: connections marked stale")
} }
// DebugBundle generates a debug bundle, uploads it, and returns the upload key. // DebugBundle generates a debug bundle, uploads it, and returns the upload key.
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mobile"
"github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/client/system"
) )
@@ -181,7 +182,7 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error {
// Stored after Login, not before: a rejected token must not leave a hint // Stored after Login, not before: a rejected token must not leave a hint
// pointing at an account that cannot be used. // pointing at an account that cannot be used.
if email != "" && a.cfgPath != "" { if email != "" && a.cfgPath != "" {
if err := writeProfileEmail(a.cfgPath, email); err != nil { if err := mobile.WriteProfileEmail(a.cfgPath, email); err != nil {
log.Warnf("failed to store profile account email: %v", err) log.Warnf("failed to store profile account email: %v", err)
} }
} }
@@ -208,7 +209,7 @@ func profileLoginHint(cfgPath string) string {
if cfgPath == "" { if cfgPath == "" {
return "" return ""
} }
return readProfileEmail(cfgPath) return mobile.ReadProfileEmail(cfgPath)
} }
// runOAuthFlow drives an already acquired OAuth flow to a token: requests the // runOAuthFlow drives an already acquired OAuth flow to a token: requests the
+62 -228
View File
@@ -3,42 +3,37 @@
package android package android
import ( import (
"fmt" "github.com/netbirdio/netbird/client/mobile"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
) )
const ( const (
// Android uses a single user context per app (non-empty username required by ServiceManager) // Android uses a single user context per app.
androidUsername = "android" androidUsername = "android"
) )
// Profile represents a profile for gomobile // Profile represents a profile for gomobile.
type Profile struct { type Profile struct {
ID string ID string
Name string Name string
// Email is the account this profile last logged in with, "" if it never // Email is the account this profile last logged in with, "" if it never
// completed an SSO login. Kept across logouts; cleared when the profile is // 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 Email string
IsActive bool IsActive bool
} }
// ProfileArray wraps profiles for gomobile compatibility // ProfileArray wraps profiles for gomobile compatibility (gomobile cannot
// bind Go slices directly).
type ProfileArray struct { type ProfileArray struct {
items []*Profile items []*Profile
} }
// Length returns the number of profiles // Length returns the number of profiles.
func (p *ProfileArray) Length() int { func (p *ProfileArray) Length() int {
return len(p.items) return len(p.items)
} }
// Get returns the profile at index i // Get returns the profile at index i, or nil if out of range.
func (p *ProfileArray) Get(i int) *Profile { func (p *ProfileArray) Get(i int) *Profile {
if i < 0 || i >= len(p.items) { if i < 0 || i >= len(p.items) {
return nil return nil
@@ -46,259 +41,98 @@ func (p *ProfileArray) Get(i int) *Profile {
return p.items[i] return p.items[i]
} }
/* // ProfileManager adapts the shared mobile profile manager (client/mobile) to
// gomobile-friendly types. See that package for the on-disk layout and
/data/data/io.netbird.client/files/ ← configDir parameter // semantics.
├── netbird.cfg ← Default profile config
├── state.json ← Default profile state
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
└── profiles/ ← Subdirectory for non-default profiles
├── work.json ← Legacy work profile config
├── work.state.json ← Legacy work profile state
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
*/
// ProfileManager manages profiles for Android
// It wraps the internal profilemanager to provide Android-specific behavior
type ProfileManager struct { type ProfileManager struct {
configDir string impl *mobile.ProfileManager
serviceMgr *profilemanager.ServiceManager
} }
// NewProfileManager creates a new profile manager for Android // NewProfileManager creates a new profile manager for Android. configDir is
// the app's files directory.
func NewProfileManager(configDir string) *ProfileManager { func NewProfileManager(configDir string) *ProfileManager {
// Set the default config path for Android (stored in root configDir, not profiles/) return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)}
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
// Set global paths for Android
profilemanager.DefaultConfigPathDir = configDir
profilemanager.DefaultConfigPath = defaultConfigPath
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
// Create ServiceManager with profiles/ subdirectory
// This avoids modifying the global ConfigDirOverride for profile listing
profilesDir := filepath.Join(configDir, profilesSubdir)
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
return &ProfileManager{
configDir: configDir,
serviceMgr: serviceMgr,
}
} }
// ListProfiles returns all available profiles // ListProfiles returns all available profiles, including the default profile,
// with their active status set.
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
// Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive) profiles, err := pm.impl.ListProfiles()
internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list profiles: %w", err) return nil, err
} }
// Convert internal profiles to Android Profile type items := make([]*Profile, 0, len(profiles))
var profiles []*Profile for i := range profiles {
for _, p := range internalProfiles { items = append(items, fromMobileProfile(&profiles[i]))
profiles = append(profiles, &Profile{
ID: p.ID.String(),
Name: p.Name,
Email: pm.profileEmail(p.ID.String()),
IsActive: p.IsActive,
})
} }
return &ProfileArray{items: items}, nil
return &ProfileArray{items: profiles}, nil
} }
// GetActiveProfile returns the currently active profile name // GetActiveProfile returns the currently active profile.
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
// Use ServiceManager to stay consistent with ListProfiles p, err := pm.impl.GetActiveProfile()
// ServiceManager uses active_profile.json
activeState, err := pm.serviceMgr.GetActiveProfileState()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get active profile: %w", err) return nil, err
} }
return fromMobileProfile(p), nil
// ActiveProfileState only stores the ID (and username), not the display
// name. Resolve the ID to the full profile so callers get the real Name.
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername)
if err != nil {
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
}
return &Profile{
ID: prof.ID.String(),
Name: prof.Name,
Email: pm.profileEmail(prof.ID.String()),
IsActive: true,
}, nil
} }
// profileEmail returns the account email recorded for a profile. Display-only, so // SwitchProfile records the given profile ID as the active profile. The caller
// an unresolvable path degrades to "" rather than an error. // must stop the VPN tunnel before switching.
func (pm *ProfileManager) profileEmail(id string) string {
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return ""
}
return readProfileEmail(configPath)
}
// SwitchProfile switches to a different profile
func (pm *ProfileManager) SwitchProfile(id string) error { func (pm *ProfileManager) SwitchProfile(id string) error {
// Use ServiceManager to stay consistent with ListProfiles return pm.impl.SwitchProfile(id)
// ServiceManager uses active_profile.json
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: profilemanager.ID(id),
Username: androidUsername,
})
if err != nil {
return fmt.Errorf("failed to switch profile: %w", err)
}
log.Infof("switched to profile: %s", id)
return nil
} }
// AddProfile creates a new profile // AddProfile creates a new profile with the given display name and a
// generated ID.
func (pm *ProfileManager) AddProfile(profileName string) error { func (pm *ProfileManager) AddProfile(profileName string) error {
// Use ServiceManager (creates profile in profiles/ directory) _, err := pm.impl.AddProfile(profileName)
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername) return err
if err != nil {
return fmt.Errorf("failed to add profile: %w", err)
}
log.Infof("created new profile: %s", profile.ID)
return nil
} }
// LogoutProfile logs out from a profile (clears authentication) // RenameProfile changes the display name of the profile identified by id. The
func (pm *ProfileManager) LogoutProfile(id string) error { // on-disk filename (the ID) is left unchanged.
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
}
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
return fmt.Errorf("id '%s' is not valid", id)
}
// Check if profile exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return fmt.Errorf("profile '%s' does not exist", id)
}
// Read current config using internal profilemanager
config, err := profilemanager.ReadConfig(configPath)
if err != nil {
return fmt.Errorf("failed to read profile config: %w", err)
}
// Clear authentication by removing private key and SSH key
config.PrivateKey = ""
config.SSHKey = ""
// Save config using internal profilemanager
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
// 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/.
func (pm *ProfileManager) RenameProfile(id string, newName string) error { func (pm *ProfileManager) RenameProfile(id string, newName string) error {
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil { return pm.impl.RenameProfile(id, newName)
return fmt.Errorf("failed to rename profile: %w", err)
}
log.Infof("renamed profile %s to: %s", id, newName)
return nil
} }
// RemoveProfile deletes a profile // LogoutProfile clears authentication data for a profile, forcing a re-login.
// The management URL and other settings are preserved.
func (pm *ProfileManager) LogoutProfile(id string) error {
return pm.impl.LogoutProfile(id)
}
// RemoveProfile deletes a profile. The default profile and the active profile
// cannot be removed.
func (pm *ProfileManager) RemoveProfile(id string) error { func (pm *ProfileManager) RemoveProfile(id string) error {
configPath, err := pm.getProfileConfigPath(id) return pm.impl.RemoveProfile(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
} }
// getProfileConfigPath returns the config file path for a profile // GetConfigPath returns the config file path for the given profile ID. Java
// This is needed for Android-specific path handling (netbird.cfg for default profile) // should call this instead of constructing paths with Preferences.configFile().
func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
return "", fmt.Errorf("id %q is not valid", id)
}
if id == profilemanager.DefaultProfileName {
// Android uses netbird.cfg for default profile instead of default.json
// Default profile is stored in root configDir, not in profiles/
return filepath.Join(pm.configDir, defaultConfigFilename), nil
}
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
return filepath.Join(profilesDir, id+".json"), nil
}
// GetConfigPath returns the config file path for a given profile id
// Java should call this instead of constructing paths with Preferences.configFile()
func (pm *ProfileManager) GetConfigPath(id string) (string, error) { func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
return pm.getProfileConfigPath(id) return pm.impl.GetConfigPath(id)
} }
// GetStateFilePath returns the state file path for a given profile // GetStateFilePath returns the state file path for the given profile ID. Java
// Java should call this instead of constructing paths with Preferences.stateFile() // should call this instead of constructing paths with Preferences.stateFile().
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
if id == "" || id == profilemanager.DefaultProfileName { return pm.impl.GetStateFilePath(id)
return filepath.Join(pm.configDir, "state.json"), nil
}
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
return "", fmt.Errorf("id %q is not valid", id)
}
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
return filepath.Join(profilesDir, id+".state.json"), nil
} }
// GetActiveConfigPath returns the config file path for the currently active profile // GetActiveConfigPath returns the config file path for the currently active
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile() // profile.
func (pm *ProfileManager) GetActiveConfigPath() (string, error) { func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
activeProfile, err := pm.GetActiveProfile() return pm.impl.GetActiveConfigPath()
if err != nil {
return "", fmt.Errorf("failed to get active profile: %w", err)
}
return pm.GetConfigPath(activeProfile.ID)
} }
// GetActiveStateFilePath returns the state file path for the currently active profile // GetActiveStateFilePath returns the state file path for the currently active
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile() // profile.
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
activeProfile, err := pm.GetActiveProfile() return pm.impl.GetActiveStateFilePath()
if err != nil { }
return "", fmt.Errorf("failed to get active profile: %w", err)
} func fromMobileProfile(p *mobile.Profile) *Profile {
return pm.GetStateFilePath(activeProfile.ID) return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive}
} }
+2 -3
View File
@@ -21,10 +21,9 @@ func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) {
if configDir == "" || profileID == "" { if configDir == "" || profileID == "" {
return nil, fmt.Errorf("profile prefs require a config dir and profile ID") return nil, fmt.Errorf("profile prefs require a config dir and profile ID")
} }
pm := NewProfileManager(configDir) prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID)
prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(profileID), androidUsername)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolve profile prefs: %w", err) return nil, err
} }
return &profilePrefs{prefs: prefs}, nil return &profilePrefs{prefs: prefs}, nil
} }
+7
View File
@@ -23,6 +23,7 @@ import (
"github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/anonymize"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/profilemanager"
) )
@@ -31,6 +32,8 @@ const (
dnsResolverAddress = "dns-resolver-address" dnsResolverAddress = "dns-resolver-address"
enableRosenpassFlag = "enable-rosenpass" enableRosenpassFlag = "enable-rosenpass"
rosenpassPermissiveFlag = "rosenpass-permissive" rosenpassPermissiveFlag = "rosenpass-permissive"
enableLocalMetricsFlag = "enable-local-metrics"
localMetricsAddressFlag = "local-metrics-address"
preSharedKeyFlag = "preshared-key" preSharedKeyFlag = "preshared-key"
interfaceNameFlag = "interface-name" interfaceNameFlag = "interface-name"
wireguardPortFlag = "wireguard-port" wireguardPortFlag = "wireguard-port"
@@ -80,6 +83,8 @@ var (
updateSettingsDisabled bool updateSettingsDisabled bool
captureEnabled bool captureEnabled bool
networksDisabled bool networksDisabled bool
localMetricsEnabled bool
localMetricsAddr string
rootCmd = &cobra.Command{ rootCmd = &cobra.Command{
Use: "netbird", Use: "netbird",
@@ -215,6 +220,8 @@ func init() {
upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.") upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.")
upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.") upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.")
upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.") upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.")
upCmd.PersistentFlags().BoolVar(&localMetricsEnabled, enableLocalMetricsFlag, false, "Enables a local Prometheus /metrics endpoint exposing connection state (peers, latency, P2P vs relay).")
upCmd.PersistentFlags().StringVar(&localMetricsAddr, localMetricsAddressFlag, localmetrics.DefaultListenAddress, "Listen address of the local Prometheus /metrics endpoint.")
upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.") upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.")
_ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable") _ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable")
+1 -1
View File
@@ -124,7 +124,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
updateManager := update_channel.NewPeersUpdateManager(metrics) updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config) networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil)
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil { if err != nil {
+67 -34
View File
@@ -398,26 +398,10 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
return nil return nil
} }
func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest { // setSSHSetConfigFields copies the SSH server flags the user actually
var req proto.SetConfigRequest // passed into req, leaving the rest unset so the daemon keeps the
req.ProfileName = profileName // persisted values.
req.Username = username func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) {
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
}
if cmd.Flag(serverSSHAllowedFlag).Changed { if cmd.Flag(serverSSHAllowedFlag).Changed {
req.ServerSSHAllowed = &serverSSHAllowed req.ServerSSHAllowed = &serverSSHAllowed
} }
@@ -446,6 +430,30 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
sshJWTCacheTTL32 := int32(sshJWTCacheTTL) sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
req.SshJWTCacheTTL = &sshJWTCacheTTL32 req.SshJWTCacheTTL = &sshJWTCacheTTL32
} }
}
func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest {
var req proto.SetConfigRequest
req.ProfileName = profileName
req.Username = username
req.ManagementUrl = managementURL
req.AdminURL = adminURL
req.NatExternalIPs = natExternalIPs
req.CustomDNSAddress = customDNSAddressConverted
req.ExtraIFaceBlacklist = extraIFaceBlackList
req.DnsLabels = dnsLabelsValidated.ToPunycodeList()
req.CleanDNSLabels = dnsLabels != nil && len(dnsLabels) == 0
req.CleanNATExternalIPs = natExternalIPs != nil && len(natExternalIPs) == 0
if cmd.Flag(enableRosenpassFlag).Changed {
req.RosenpassEnabled = &rosenpassEnabled
}
if cmd.Flag(rosenpassPermissiveFlag).Changed {
req.RosenpassPermissive = &rosenpassPermissive
}
setSSHSetConfigFields(&req, cmd)
if cmd.Flag(interfaceNameFlag).Changed { if cmd.Flag(interfaceNameFlag).Changed {
if err := parseInterfaceName(interfaceName); err != nil { if err := parseInterfaceName(interfaceName); err != nil {
log.Errorf("parse interface name: %v", err) log.Errorf("parse interface name: %v", err)
@@ -505,6 +513,13 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
req.DisableIpv6 = &disableIPv6 req.DisableIpv6 = &disableIPv6
} }
if cmd.Flag(enableLocalMetricsFlag).Changed {
req.EnableLocalMetrics = &localMetricsEnabled
}
if cmd.Flag(localMetricsAddressFlag).Changed {
req.LocalMetricsAddress = &localMetricsAddr
}
return &req return &req
} }
@@ -606,6 +621,14 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
ic.DisableIPv6 = &disableIPv6 ic.DisableIPv6 = &disableIPv6
} }
if cmd.Flag(enableLocalMetricsFlag).Changed {
ic.LocalMetricsEnabled = &localMetricsEnabled
}
if cmd.Flag(localMetricsAddressFlag).Changed {
ic.LocalMetricsAddress = &localMetricsAddr
}
return &ic, nil return &ic, nil
} }
@@ -630,7 +653,19 @@ func applySSHFlagsToConfig(cmd *cobra.Command, ic *profilemanager.ConfigInput) {
} }
} }
func applySSHFlagsToLogin(cmd *cobra.Command, req *proto.LoginRequest) { // setSSHLoginFields copies the SSH and VNC 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(serverVNCAllowedFlag).Changed {
req.ServerVNCAllowed = &serverVNCAllowed
}
if cmd.Flag(disableVNCApprovalFlag).Changed {
req.DisableVNCApproval = &disableVNCApproval
}
if cmd.Flag(enableSSHRootFlag).Changed { if cmd.Flag(enableSSHRootFlag).Changed {
req.EnableSSHRoot = &enableSSHRoot req.EnableSSHRoot = &enableSSHRoot
} }
@@ -647,8 +682,8 @@ func applySSHFlagsToLogin(cmd *cobra.Command, req *proto.LoginRequest) {
req.DisableSSHAuth = &disableSSHAuth req.DisableSSHAuth = &disableSSHAuth
} }
if cmd.Flag(sshJWTCacheTTLFlag).Changed { if cmd.Flag(sshJWTCacheTTLFlag).Changed {
ttl := int32(sshJWTCacheTTL) sshJWTCacheTTL32 := int32(sshJWTCacheTTL)
req.SshJWTCacheTTL = &ttl req.SshJWTCacheTTL = &sshJWTCacheTTL32
} }
} }
@@ -678,22 +713,20 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
loginRequest.RosenpassPermissive = &rosenpassPermissive loginRequest.RosenpassPermissive = &rosenpassPermissive
} }
if cmd.Flag(serverSSHAllowedFlag).Changed { setSSHLoginFields(&loginRequest, cmd)
loginRequest.ServerSSHAllowed = &serverSSHAllowed
}
if cmd.Flag(serverVNCAllowedFlag).Changed {
loginRequest.ServerVNCAllowed = &serverVNCAllowed
}
if cmd.Flag(disableVNCApprovalFlag).Changed {
loginRequest.DisableVNCApproval = &disableVNCApproval
}
applySSHFlagsToLogin(cmd, &loginRequest)
if cmd.Flag(disableAutoConnectFlag).Changed { if cmd.Flag(disableAutoConnectFlag).Changed {
loginRequest.DisableAutoConnect = &autoConnectDisabled loginRequest.DisableAutoConnect = &autoConnectDisabled
} }
if cmd.Flag(enableLocalMetricsFlag).Changed {
loginRequest.EnableLocalMetrics = &localMetricsEnabled
}
if cmd.Flag(localMetricsAddressFlag).Changed {
loginRequest.LocalMetricsAddress = &localMetricsAddr
}
if cmd.Flag(interfaceNameFlag).Changed { if cmd.Flag(interfaceNameFlag).Changed {
if err := parseInterfaceName(interfaceName); err != nil { if err := parseInterfaceName(interfaceName); err != nil {
return nil, err return nil, err
+7
View File
@@ -85,6 +85,11 @@ type Options struct {
DisableIPv6 bool DisableIPv6 bool
// BlockInbound blocks all inbound connections from peers // BlockInbound blocks all inbound connections from peers
BlockInbound bool BlockInbound bool
// EnableRosenpass enables the Rosenpass post-quantum key exchange.
EnableRosenpass bool
// RosenpassPermissive lets a Rosenpass-enabled peer still connect to peers
// that do not run Rosenpass (falling back to the plain WireGuard PSK).
RosenpassPermissive bool
// BlockLANAccess blocks the embedded peer from reaching the host's // BlockLANAccess blocks the embedded peer from reaching the host's
// LAN (RFC 1918, link-local, loopback) when it's used as a routing // LAN (RFC 1918, link-local, loopback) when it's used as a routing
// peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful // peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
@@ -210,6 +215,8 @@ func New(opts Options) (*Client, error) {
DisableIPv6: &opts.DisableIPv6, DisableIPv6: &opts.DisableIPv6,
BlockInbound: &opts.BlockInbound, BlockInbound: &opts.BlockInbound,
BlockLANAccess: &opts.BlockLANAccess, BlockLANAccess: &opts.BlockLANAccess,
RosenpassEnabled: &opts.EnableRosenpass,
RosenpassPermissive: &opts.RosenpassPermissive,
WireguardPort: opts.WireguardPort, WireguardPort: opts.WireguardPort,
MTU: opts.MTU, MTU: opts.MTU,
DNSLabels: parsedLabels, DNSLabels: parsedLabels,
+1 -1
View File
@@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string {
updateManager := update_channel.NewPeersUpdateManager(metrics) updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore) requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg) networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil)
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
require.NoError(t, err) require.NoError(t, err)
+1 -1
View File
@@ -763,7 +763,7 @@ func (r *router) addNatRule(pair firewall.RouterPair) error {
exprs = append(exprs, sourceExp...) exprs = append(exprs, sourceExp...)
exprs = append(exprs, destExp...) exprs = append(exprs, destExp...)
var markValue uint32 = nbnet.PreroutingFwmarkMasquerade markValue := nbnet.PreroutingFwmarkMasquerade
if pair.Inverse { if pair.Inverse {
markValue = nbnet.PreroutingFwmarkMasqueradeReturn markValue = nbnet.PreroutingFwmarkMasqueradeReturn
} }
+7 -2
View File
@@ -16,9 +16,14 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
nbnet "github.com/netbirdio/netbird/client/net" nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/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 { func WithCustomDialer(_ bool, _ string) grpc.DialOption {
return grpc.WithContextDialer(dialContext) return grpc.WithContextDialer(dialContext)
} }
@@ -26,7 +31,7 @@ func WithCustomDialer(_ bool, _ string) grpc.DialOption {
// WithSweeper dials like WithCustomDialer but registers connections and // WithSweeper dials like WithCustomDialer but registers connections and
// dials with the sweeper. Append it after WithCustomDialer: gRPC applies // dials with the sweeper. Append it after WithCustomDialer: gRPC applies
// dial options in order, so the later context dialer wins. // 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) { return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
dial := sweeper.StartDial(ctx) dial := sweeper.StartDial(ctx)
defer dial.Release() defer dial.Release()
+9 -2
View File
@@ -1,12 +1,19 @@
package grpc package grpc
import ( import (
"context"
"google.golang.org/grpc" "google.golang.org/grpc"
"github.com/netbirdio/netbird/client/netsweep" "github.com/netbirdio/netbird/client/netevents/sweep"
"github.com/netbirdio/netbird/util/wsproxy/client" "github.com/netbirdio/netbird/util/wsproxy/client"
) )
// Sweeper registers in-flight dials for the network change sweep.
type Sweeper interface {
StartDial(ctx context.Context) *sweep.Dial
}
// WithCustomDialer returns a gRPC dial option that uses WebSocket transport for WASM/JS environments. // WithCustomDialer returns a gRPC dial option that uses WebSocket transport for WASM/JS environments.
// The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal"). // The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal").
func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption { func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption {
@@ -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. // 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{} return grpc.EmptyDialOption{}
} }
+12 -5
View File
@@ -6,16 +6,19 @@ import (
"time" "time"
"github.com/cenkalti/backoff/v4" "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 // Retry mirrors backoff.Retry, but the sleep between attempts also wakes on
// OS network availability transitions: an operation cut down by a network // OS network availability transitions: an operation cut down by a network
// change retries the moment the network settles instead of sleeping through // 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. // 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() bo.Reset()
for { for {
err := operation() err := operation()
@@ -36,10 +39,14 @@ func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff,
return err return err
} }
var changed <-chan struct{}
if watcher != nil {
changed = watcher.Changed()
}
timer := time.NewTimer(next) timer := time.NewTimer(next)
select { select {
case <-timer.C: case <-timer.C:
case <-netState.Changed(): case <-changed:
timer.Stop() timer.Stop()
case <-ctx.Done(): case <-ctx.Done():
timer.Stop() timer.Stop()
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/client/netevents/netstate"
) )
func TestRetryWakesOnNetworkChange(t *testing.T) { func TestRetryWakesOnNetworkChange(t *testing.T) {
+1 -1
View File
@@ -502,7 +502,7 @@ func toBytes(s string) (int64, error) {
func getFwmark() int { func getFwmark() int {
if nbnet.AdvancedRouting() && runtime.GOOS == "linux" { if nbnet.AdvancedRouting() && runtime.GOOS == "linux" {
return nbnet.ControlPlaneMark return int(nbnet.ControlPlaneMark)
} }
return 0 return 0
} }
+6 -10
View File
@@ -10,8 +10,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
nbnet "github.com/netbirdio/netbird/client/net"
) )
// PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets // PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets
@@ -60,14 +58,12 @@ func prepareSenderRawSocket(family int, isIPv4 bool) (net.PacketConn, error) {
return nil, fmt.Errorf("binding to lo interface failed: %w", err) return nil, fmt.Errorf("binding to lo interface failed: %w", err)
} }
// Set the fwmark on the socket. // The socket is bound to lo and only ever sends to the local WireGuard
err = nbnet.SetSocketOpt(fd) // instance, a destination the local routing table resolves without help, so
if err != nil { // it carries no fwmark. Staying unmarked also keeps these packets out of
if closeErr := syscall.Close(fd); closeErr != nil { // third-party NAT rules that match on marks: such a rule rewriting the
log.Warnf("failed to close raw socket fd: %v", closeErr) // source would make WireGuard adopt the rewritten address as the peer
} // endpoint.
return nil, fmt.Errorf("setting fwmark failed: %w", err)
}
// Convert the file descriptor to a PacketConn. // Convert the file descriptor to a PacketConn.
file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd)) file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd))
@@ -0,0 +1,77 @@
//go:build linux && !android && privileged
package rawsocket
import (
"net"
"syscall"
"testing"
"golang.org/x/sys/unix"
nbnet "github.com/netbirdio/netbird/client/net"
)
// The sender sockets must stay unmarked: a NAT rule matching on fwmark that
// rewrites the source of an injected packet makes WireGuard adopt the rewritten
// address as the peer endpoint.
func TestSenderRawSocketsCarryNoFwmark(t *testing.T) {
// the mark is only ever applied when advanced routing is available, so
// without it the assertion below would hold for the wrong reason
nbnet.Init()
if !nbnet.AdvancedRouting() {
t.Skip("advanced routing unsupported, the sockets carry no mark either way")
}
tests := []struct {
name string
prepare func() (net.PacketConn, error)
// the proxy treats the IPv6 socket as optional, so a host without IPv6
// is a reason to skip rather than to fail
optional bool
}{
{name: "IPv4", prepare: PrepareSenderRawSocketIPv4},
{name: "IPv6", prepare: PrepareSenderRawSocketIPv6, optional: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
conn, err := tc.prepare()
if err != nil {
if tc.optional {
t.Skipf("prepare raw socket: %v", err)
}
t.Fatalf("prepare raw socket: %v", err)
}
defer func() {
if err := conn.Close(); err != nil {
t.Logf("close raw socket: %v", err)
}
}()
syscallConn, ok := conn.(syscall.Conn)
if !ok {
t.Fatalf("raw socket %T does not expose a syscall conn", conn)
}
raw, err := syscallConn.SyscallConn()
if err != nil {
t.Fatalf("syscall conn: %v", err)
}
var mark int
var markErr error
if err := raw.Control(func(fd uintptr) {
mark, markErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK)
}); err != nil {
t.Fatalf("control: %v", err)
}
if markErr != nil {
t.Fatalf("get SO_MARK: %v", markErr)
}
if mark != 0 {
t.Errorf("SO_MARK = %#x, want 0", mark)
}
})
}
}
+68 -104
View File
@@ -2,6 +2,7 @@ package internal
import ( import (
"context" "context"
"maps"
"os" "os"
"strconv" "strconv"
"sync" "sync"
@@ -14,6 +15,7 @@ import (
"github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore" "github.com/netbirdio/netbird/client/internal/peerstore"
"github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/route"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
) )
// lazyForce is the resolved local decision for lazy connections, layered above the // lazyForce is the resolved local decision for lazy connections, layered above the
@@ -37,11 +39,13 @@ const (
// The only exception is ActivatePeer, which is safe for concurrent use so the // The only exception is ActivatePeer, which is safe for concurrent use so the
// DNS warm-up path can call it without contending on the engine mutex. // DNS warm-up path can call it without contending on the engine mutex.
type ConnMgr struct { type ConnMgr struct {
peerStore *peerstore.Store peerStore *peerstore.Store
statusRecorder *peer.Status statusRecorder *peer.Status
iface lazyconn.WGIface iface lazyconn.WGIface
force lazyForce force lazyForce
rosenpassEnabled bool // remoteLazyEnabled caches the account-wide lazy feature flag from management.
// It is the default for peers that do not carry a per-peer lazy hint.
remoteLazyEnabled bool
lazyConnMgr *manager.Manager lazyConnMgr *manager.Manager
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the // lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
@@ -53,6 +57,10 @@ type ConnMgr struct {
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile. // (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
reconcileRoutedIPs func(peerKey string) error reconcileRoutedIPs func(peerKey string) error
// appliedExcludeList is the exclude set last handed to the lazy manager, kept so an
// unchanged set on the next sync skips the O(n) reconciliation.
appliedExcludeList map[string]bool
wg sync.WaitGroup wg sync.WaitGroup
lazyCtx context.Context lazyCtx context.Context
lazyCtxCancel context.CancelFunc lazyCtxCancel context.CancelFunc
@@ -66,78 +74,59 @@ func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
e := &ConnMgr{ e := &ConnMgr{
peerStore: peerStore, peerStore: peerStore,
statusRecorder: statusRecorder, statusRecorder: statusRecorder,
iface: iface, iface: iface,
force: resolveLazyForce(engineConfig.LazyConnection), force: resolveLazyForce(engineConfig.LazyConnection),
rosenpassEnabled: engineConfig.RosenpassEnabled,
} }
return e return e
} }
// Start initializes the connection manager. It starts the lazy connection manager when a // Start initializes the connection manager. The lazy connection manager always runs so that
// local override forces it on; with no local override it waits for the management feature flag. // per-peer lazy defaults (e.g. proxy peers) work even when the account flag is off; the
// account flag and the local override decide the default lazy state per peer (see
// PeerLazyDefault). Rosenpass peers stay lazy-capable too: their connections just never idle
// on their own, since rosenpass rekey traffic keeps them active.
func (e *ConnMgr) Start(ctx context.Context) { func (e *ConnMgr) Start(ctx context.Context) {
if e.lazyConnMgr != nil { if e.lazyConnMgr != nil {
log.Errorf("lazy connection manager is already started") log.Errorf("lazy connection manager is already started")
return return
} }
switch e.force {
case lazyForceOff:
log.Infof("lazy connection manager is disabled by local override (%s or MDM policy)", lazyconn.EnvLazyConn)
e.statusRecorder.UpdateLazyConnection(false)
return
case lazyForceNone:
log.Infof("lazy connection manager is managed by the management feature flag")
e.statusRecorder.UpdateLazyConnection(false)
return
}
if e.rosenpassEnabled {
log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started")
e.statusRecorder.UpdateLazyConnection(false)
return
}
e.initLazyManager(ctx) e.initLazyManager(ctx)
e.statusRecorder.UpdateLazyConnection(true) e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault))
} }
// UpdatedRemoteFeatureFlag is called when the remote feature flag is updated. // UpdatedRemoteFeatureFlag caches the account-wide lazy feature flag. The manager itself is
// If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again. // not started or stopped here; the per-sync exclude-list reconciliation moves normal peers
// If disabled, then it closes the lazy connection manager and open the connections to all peers. // between the lazy and always-active sets when the flag flips.
func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error { func (e *ConnMgr) UpdatedRemoteFeatureFlag(_ context.Context, enabled bool) error {
// a local override (NB_LAZY_CONN or local config) takes precedence over management e.remoteLazyEnabled = enabled
if e.force != lazyForceNone { if e.isStartedWithLazyMgr() {
return nil e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault))
}
return nil
}
// PeerLazyDefault reports whether a peer should be lazy. The local override
// (NB_LAZY_CONN/MDM) wins over everything; without a local override the
// management per-peer state applies (LazyStateLazy/Eager force the decision),
// and LazyStateDefault follows the account-wide flag.
func (e *ConnMgr) PeerLazyDefault(state mgmProto.LazyState) bool {
switch e.force {
case lazyForceOn:
return true
case lazyForceOff:
return false
} }
if enabled { switch state {
// if the lazy connection manager is already started, do not start it again case mgmProto.LazyState_LazyStateLazy:
if e.lazyConnMgr != nil { return true
return nil case mgmProto.LazyState_LazyStateEager:
} return false
default:
if e.rosenpassEnabled { return e.remoteLazyEnabled
log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started")
e.statusRecorder.UpdateLazyConnection(false)
return nil
}
log.Infof("lazy connection manager is enabled by the management feature flag")
e.initLazyManager(ctx)
e.statusRecorder.UpdateLazyConnection(true)
return e.addPeersToLazyConnManager()
} else {
if e.lazyConnMgr == nil {
e.statusRecorder.UpdateLazyConnection(false)
return nil
}
log.Infof("lazy connection manager is disabled by management feature flag")
e.closeManager(ctx)
e.statusRecorder.UpdateLazyConnection(false)
return nil
} }
} }
@@ -157,6 +146,13 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) {
return return
} }
// The exclude set is recomputed every sync but rarely changes; skip the O(n)
// store lookups and reconciliation when it matches what was already applied.
if maps.Equal(peerIDs, e.appliedExcludeList) {
return
}
e.appliedExcludeList = maps.Clone(peerIDs)
excludedPeers := make([]lazyconn.PeerConfig, 0, len(peerIDs)) excludedPeers := make([]lazyconn.PeerConfig, 0, len(peerIDs))
for peerID := range peerIDs { for peerID := range peerIDs {
@@ -192,12 +188,16 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) {
} }
} }
func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn) (exists bool) { // AddPeerConn registers a peer connection. permanent requests an always-active connection
// (the peer belongs to the exclude set: a forwarder, or a peer that is not lazy by policy).
// Non-permanent peers are handed to the lazy manager. The subsequent SetExcludeList call
// reconciles membership for existing peers across flag flips.
func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, permanent bool) (exists bool) {
if success := e.peerStore.AddPeerConn(peerKey, conn); !success { if success := e.peerStore.AddPeerConn(peerKey, conn); !success {
return true return true
} }
if !e.isStartedWithLazyMgr() { if !e.isStartedWithLazyMgr() || permanent {
if err := conn.Open(ctx); err != nil { if err := conn.Open(ctx); err != nil {
conn.Log.Errorf("failed to open connection: %v", err) conn.Log.Errorf("failed to open connection: %v", err)
} }
@@ -296,6 +296,8 @@ func (e *ConnMgr) Close() {
e.lazyConnMgrMu.Lock() e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil e.lazyConnMgr = nil
e.lazyConnMgrMu.Unlock() e.lazyConnMgrMu.Unlock()
e.appliedExcludeList = nil
} }
func (e *ConnMgr) initLazyManager(engineCtx context.Context) { func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
@@ -309,6 +311,8 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx) e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
e.lazyConnMgrMu.Unlock() e.lazyConnMgrMu.Unlock()
e.appliedExcludeList = nil
e.wg.Add(1) e.wg.Add(1)
go func() { go func() {
defer e.wg.Done() defer e.wg.Done()
@@ -316,46 +320,6 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
}() }()
} }
func (e *ConnMgr) addPeersToLazyConnManager() error {
peers := e.peerStore.PeersPubKey()
lazyPeerCfgs := make([]lazyconn.PeerConfig, 0, len(peers))
for _, peerID := range peers {
var peerConn *peer.Conn
var exists bool
if peerConn, exists = e.peerStore.PeerConn(peerID); !exists {
log.Warnf("failed to find peer conn for peerID: %s", peerID)
continue
}
lazyPeerCfg := lazyconn.PeerConfig{
PublicKey: peerID,
AllowedIPs: peerConn.WgConfig().AllowedIps,
PeerConnID: peerConn.ConnID(),
Log: peerConn.Log,
}
lazyPeerCfgs = append(lazyPeerCfgs, lazyPeerCfg)
}
return e.lazyConnMgr.AddActivePeers(lazyPeerCfgs)
}
func (e *ConnMgr) closeManager(ctx context.Context) {
if e.lazyConnMgr == nil {
return
}
e.lazyCtxCancel()
e.wg.Wait()
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil
e.lazyConnMgrMu.Unlock()
for _, peerID := range e.peerStore.PeersPubKey() {
e.peerStore.PeerConnOpen(ctx, peerID)
}
}
func (e *ConnMgr) isStartedWithLazyMgr() bool { func (e *ConnMgr) isStartedWithLazyMgr() bool {
return e.lazyConnMgr != nil && e.lazyCtxCancel != nil return e.lazyConnMgr != nil && e.lazyCtxCancel != nil
} }
+88
View File
@@ -16,6 +16,7 @@ import (
"github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore" "github.com/netbirdio/netbird/client/internal/peerstore"
"github.com/netbirdio/netbird/monotime" "github.com/netbirdio/netbird/monotime"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
) )
func TestResolveLazyForce(t *testing.T) { func TestResolveLazyForce(t *testing.T) {
@@ -138,4 +139,91 @@ func TestInactivityThresholdEnv(t *testing.T) {
} }
} }
func TestPeerLazyDefault(t *testing.T) {
tests := []struct {
name string
force lazyForce
remoteEnabled bool
state mgmProto.LazyState
want bool
}{
{name: "force on wins over eager state", force: lazyForceOn, state: mgmProto.LazyState_LazyStateEager, want: true},
{name: "force off wins over lazy state", force: lazyForceOff, remoteEnabled: true, state: mgmProto.LazyState_LazyStateLazy, want: false},
{name: "none, default, account off -> active", force: lazyForceNone, state: mgmProto.LazyState_LazyStateDefault, want: false},
{name: "none, default, account on -> lazy", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateDefault, want: true},
{name: "none, lazy state, account off -> lazy", force: lazyForceNone, state: mgmProto.LazyState_LazyStateLazy, want: true},
{name: "none, eager state, account on -> active", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateEager, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled}
if got := e.PeerLazyDefault(tt.state); got != tt.want {
t.Fatalf("PeerLazyDefault(%v) = %v, want %v", tt.state, got, tt.want)
}
})
}
}
func durPtr(d time.Duration) *time.Duration { return &d } func durPtr(d time.Duration) *time.Duration { return &d }
// TestToExcludedLazyPeers covers the per-peer lazy classification (proxy vs
// normal, across the force/account-flag matrix). Forwarder-target exclusion is
// covered by TestToExcludedLazyPeers_ForwardTarget.
func TestToExcludedLazyPeers(t *testing.T) {
const (
normalKey = "normal"
lazyKey = "lazy-state"
eagerKey = "eager-state"
)
peers := []*mgmProto.RemotePeerConfig{
{WgPubKey: normalKey, AllowedIps: []string{"100.64.0.1/32"}},
{WgPubKey: lazyKey, AllowedIps: []string{"100.64.0.2/32"}, LazyState: mgmProto.LazyState_LazyStateLazy},
{WgPubKey: eagerKey, AllowedIps: []string{"100.64.0.3/32"}, LazyState: mgmProto.LazyState_LazyStateEager},
}
tests := []struct {
name string
force lazyForce
remoteEnabled bool
want map[string]bool
}{
{
name: "account off: lazy-state peer lazy, normal + eager active",
force: lazyForceNone, remoteEnabled: false,
want: map[string]bool{normalKey: true, eagerKey: true},
},
{
name: "account on: only eager-state peer active",
force: lazyForceNone, remoteEnabled: true,
want: map[string]bool{eagerKey: true},
},
{
name: "force off: everything active",
force: lazyForceOff, remoteEnabled: true,
want: map[string]bool{normalKey: true, lazyKey: true, eagerKey: true},
},
{
name: "force on: nothing active",
force: lazyForceOn, remoteEnabled: false,
want: map[string]bool{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := &Engine{connMgr: &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled}}
got := e.toExcludedLazyPeers(peers)
if len(got) != len(tt.want) {
t.Fatalf("toExcludedLazyPeers() = %v, want %v", got, tt.want)
}
for k := range tt.want {
if !got[k] {
t.Fatalf("expected peer %s excluded, got %v", k, got)
}
}
})
}
}
+14 -26
View File
@@ -38,8 +38,7 @@ import (
"github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/internal/updater/installer" "github.com/netbirdio/netbird/client/internal/updater/installer"
nbnet "github.com/netbirdio/netbird/client/net" nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/client/netevents"
"github.com/netbirdio/netbird/client/netsweep"
cProto "github.com/netbirdio/netbird/client/proto" cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/ssh"
sshconfig "github.com/netbirdio/netbird/client/ssh/config" sshconfig "github.com/netbirdio/netbird/client/ssh/config"
@@ -73,28 +72,17 @@ type ConnectClient struct {
persistSyncResponse bool persistSyncResponse bool
// netState gates every reconnection loop on OS-reported network // netMgr gates every reconnection loop on OS-reported network
// availability. Nil (the default) disables gating; mobile platforms // availability and sweeps connections on network change.
// inject it via WithNetworkState. netMgr *netevents.Manager
netState *netstate.State
// sweeper cuts the management, signal and relay connections on network
// change; nil disables it.
sweeper *netsweep.Sweeper
} }
// ConnectClientOption configures optional ConnectClient behavior. // ConnectClientOption configures optional ConnectClient behavior.
type ConnectClientOption func(*ConnectClient) type ConnectClientOption func(*ConnectClient)
// WithNetworkState injects the OS network availability state that gates every // WithNetEvents injects the OS network event handling.
// reconnection loop; without it gating is disabled. func WithNetEvents(events *netevents.Manager) ConnectClientOption {
func WithNetworkState(netState *netstate.State) ConnectClientOption { return func(c *ConnectClient) { c.netMgr = events }
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 }
} }
func NewConnectClient( func NewConnectClient(
@@ -305,7 +293,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
} }
// suspend connection attempts while the OS reports no usable network // 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 return nil
} else if waited { } else if waited {
backOff.Reset() 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) log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host)
mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled, mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled,
mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper)) mgm.WithNetEvents(c.netMgr))
if err != nil { if err != nil {
// On daemon shutdown / Down() the parent context is cancelled // On daemon shutdown / Down() the parent context is cancelled
// and the dial fails with "context canceled". Wrapping that // and the dial fails with "context canceled". Wrapping that
@@ -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 // 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 { if err != nil {
log.Error(err) log.Error(err)
return wrapErr(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, 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) c.statusRecorder.SetRelayMgr(relayManager)
if len(relayURLs) > 0 { if len(relayURLs) > 0 {
if token != nil { if token != nil {
@@ -463,7 +451,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
UpdateManager: c.updateManager, UpdateManager: c.updateManager,
ClientMetrics: c.clientMetrics, ClientMetrics: c.clientMetrics,
MetricsCtx: c.ctx, MetricsCtx: c.ctx,
NetState: c.netState, NetMgr: c.netMgr,
}, mobileDependency) }, mobileDependency)
engine.SetSyncResponsePersistence(c.persistSyncResponse) engine.SetSyncResponsePersistence(c.persistSyncResponse)
c.engine = engine c.engine = engine
@@ -725,7 +713,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 {
} }
// connectToSignal creates Signal Service client and established a connection // connectToSignal creates Signal Service client and established a connection
func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, 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 var sigTLSEnabled bool
if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS { if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS {
sigTLSEnabled = true sigTLSEnabled = true
@@ -734,7 +722,7 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP
} }
signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled, signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled,
signal.WithNetworkState(netState), signal.WithSweeper(sweeper)) signal.WithNetEvents(netMgr))
if err != nil { if err != nil {
log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err) log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err)
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err) return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err)
+17
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)
}
@@ -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)
})
}
}
+2
View File
@@ -743,6 +743,8 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess)) configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound)) configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6)) configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
configContent.WriteString(fmt.Sprintf("LocalMetricsEnabled: %v\n", g.internalConfig.LocalMetricsEnabled))
configContent.WriteString(fmt.Sprintf("LocalMetricsAddress: %s\n", g.internalConfig.LocalMetricsAddress))
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion)) configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
if g.internalConfig.DisableNotifications != nil { if g.internalConfig.DisableNotifications != nil {
@@ -224,6 +224,7 @@ func TestResolver_StaleTriggersAsyncRefresh(t *testing.T) {
} }
func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) {
semaphore := make(chan struct{})
r := NewResolver() r := NewResolver()
chain := newFakeChain() chain := newFakeChain()
chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2") chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2")
@@ -239,7 +240,7 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) {
break break
} }
} }
time.Sleep(50 * time.Millisecond) // hold inflight long enough to collide <-semaphore // block the call to force request collision
} }
r.SetChainResolver(chain, 50) r.SetChainResolver(chain, 50)
@@ -255,17 +256,17 @@ func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) {
var wg sync.WaitGroup var wg sync.WaitGroup
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
wg.Add(1) wg.Go(func() {
go func() {
defer wg.Done()
queryA(t, r, "mgmt.example.com.") queryA(t, r, "mgmt.example.com.")
}() })
} }
assert.Eventually(t, func() bool { return inflight.Load() >= 1 }, 2*time.Second, 100*time.Millisecond)
close(semaphore)
wg.Wait() wg.Wait()
waitFor(t, 2*time.Second, func() bool { assert.Eventually(t, func() bool { return inflight.Load() == 0 }, 2*time.Second, 100*time.Millisecond)
return inflight.Load() == 0
})
calls := chain.callCount("mgmt.example.com.", dns.TypeA) calls := chain.callCount("mgmt.example.com.", dns.TypeA)
assert.LessOrEqual(t, calls, 2, "singleflight must collapse concurrent refreshes (got %d)", calls) assert.LessOrEqual(t, calls, 2, "singleflight must collapse concurrent refreshes (got %d)", calls)
+87
View File
@@ -8,7 +8,9 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/iface/wgaddr"
nbdns "github.com/netbirdio/netbird/dns" nbdns "github.com/netbirdio/netbird/dns"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
) )
func TestCreatePTRRecord_IPv4(t *testing.T) { func TestCreatePTRRecord_IPv4(t *testing.T) {
@@ -136,3 +138,88 @@ func TestAddReverseZone_IPv6(t *testing.T) {
assert.Len(t, reverseZone.Records, 1) assert.Len(t, reverseZone.Records, 1)
assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type) assert.Equal(t, int(dns.TypePTR), reverseZone.Records[0].Type)
} }
// TestToDNSConfig_ZoneFlagsPreserved pins the per-zone NonAuthoritative flag
// through the legacy DNSConfig path. A non-authoritative zone is match-only:
// the local resolver falls through to the upstream for an in-zone name it does
// not define. The built-in peer zone is the authoritative one and must stay
// that way, so the flag has to travel per zone rather than be derived.
func TestToDNSConfig_ZoneFlagsPreserved(t *testing.T) {
config := toDNSConfig(&mgmProto.DNSConfig{
ServiceEnable: true,
CustomZones: []*mgmProto.CustomZone{
{
Domain: "netbird.cloud.",
Records: []*mgmProto.SimpleRecord{
{Name: "peer1.netbird.cloud.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.1"},
},
},
{
Domain: "corp.internal.",
NonAuthoritative: true,
SearchDomainDisabled: true,
Records: []*mgmProto.SimpleRecord{
{Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"},
},
},
},
}, wgaddr.Address{
IP: netip.MustParseAddr("100.64.0.1"),
Network: netip.MustParsePrefix("100.64.0.0/16"),
})
zones := make(map[string]nbdns.CustomZone, len(config.CustomZones))
for _, zone := range config.CustomZones {
zones[zone.Domain] = zone
}
peerZone, ok := zones["netbird.cloud."]
require.True(t, ok, "peer zone must survive")
assert.False(t, peerZone.NonAuthoritative, "the built-in peer zone owns the account domain and stays authoritative")
accountZone, ok := zones["corp.internal."]
require.True(t, ok, "account zone must survive")
assert.True(t, accountZone.NonAuthoritative, "an account zone stays match-only, else undefined in-zone names get black-holed")
assert.True(t, accountZone.SearchDomainDisabled)
}
// TestToDNSConfig_SingleZoneForcedAuthoritative pins the compatibility clause
// in toDNSConfig: a config carrying exactly one zone is treated as
// authoritative no matter what the server said, because servers that predate
// the NonAuthoritative field send only the peer FQDN zone.
//
// The clause can only ever downgrade an explicit true to false, so a server
// that legitimately sends a single non-authoritative zone — an account whose
// only zone is a custom one, with no peer records to build the built-in zone
// from — gets that zone's whole apex black-holed on the client. Real accounts
// always carry the peer zone alongside, which is why this is latent. Narrowing
// it needs a way to tell "unset" from "false" on the wire, or the account
// domain passed down here; until then this test states the contract so a
// change to it is deliberate.
func TestToDNSConfig_SingleZoneForcedAuthoritative(t *testing.T) {
config := toDNSConfig(&mgmProto.DNSConfig{
ServiceEnable: true,
CustomZones: []*mgmProto.CustomZone{
{
Domain: "corp.internal.",
NonAuthoritative: true,
Records: []*mgmProto.SimpleRecord{
{Name: "db.corp.internal.", Type: int64(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.0.5"},
},
},
},
}, wgaddr.Address{
IP: netip.MustParseAddr("100.64.0.1"),
Network: netip.MustParsePrefix("100.64.0.0/16"),
})
require.NotEmpty(t, config.CustomZones)
assert.Equal(t, "corp.internal.", config.CustomZones[0].Domain)
assert.False(t, config.CustomZones[0].NonAuthoritative,
"a lone zone is forced authoritative for pre-NonAuthoritative servers")
// The reverse zone the config gains afterwards must not feed back into the
// decision: the compat gate counts the zones the server sent.
require.Len(t, config.CustomZones, 2, "a reverse zone is appended for the overlay prefix")
assert.Equal(t, "64.100.in-addr.arpa.", config.CustomZones[1].Domain)
}
+74
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()
}
+18
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
}
+21
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)
}
}
+359
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]
}
+111
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")
}
+117
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
}
+110
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")
}
@@ -0,0 +1,19 @@
//go:build !windows && !darwin && !linux
package elevate
import "context"
// run reports that this platform has no elevation prompt to drive.
//
// The desktop app is the only caller and is not built for any of these: mobile
// and WASM have no local user to ask, and the FreeBSD client ships without a UI.
// pkexec would be the mechanism there, and run_unix.go is what to widen if that
// changes.
func run(context.Context, string, []string) error {
return ErrUnavailable
}
func mechanismAvailable() bool {
return false
}
+187
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
}
+40
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
}
@@ -0,0 +1,10 @@
package elevate
// adminWriteGIDs are the groups whose write access to an executable does not
// widen who could authorize elevating it.
//
// macOS installs applications as root:admin, mode 0775, /Applications included,
// so requiring owner-only write would reject every normal install. Group admin
// (gid 80) is exactly the set of accounts that can answer the authentication
// dialog, so its write access grants nothing the prompt would not.
var adminWriteGIDs = []uint32{0, 80}
@@ -0,0 +1,9 @@
//go:build !windows && !darwin
package elevate
// adminWriteGIDs are the groups whose write access to an executable does not
// widen who could authorize elevating it. Only root's own group qualifies here:
// a distribution installs into root-owned directories, and there is no
// system-wide administrators group that both writes them and answers polkit.
var adminWriteGIDs = []uint32{0}
+119
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 })
}
@@ -0,0 +1,148 @@
//go:build !windows
package elevate
import (
"os"
"os/user"
"path/filepath"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its
// numbered directory with 0777 minus the umask, so under the common 002 umask it
// is group-writable and would fail the check under test on its own.
func ownerOnlyDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory")
return dir
}
// writeExecutable creates a plain executable file, the shape trustedSelf checks.
func writeExecutable(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "netbird-ui")
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable")
require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode")
return path
}
func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) {
err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t)))
assert.NoError(t, err, "an owner-only writable executable is trustworthy")
}
func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) {
path := writeExecutable(t, ownerOnlyDir(t))
require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable")
assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused")
}
// The permission policy on its own, without a filesystem to arrange: whether the
// group has been vouched for is the only thing that makes group write acceptable.
func TestWriteBitsAllow(t *testing.T) {
tests := []struct {
name string
perm os.FileMode
sticky bool
groupAllowed bool
wantErr bool
}{
{name: "owner only", perm: 0o755},
{name: "group write in a private group", perm: 0o775, groupAllowed: true},
{name: "group write in a shared group", perm: 0o775, wantErr: true},
{name: "world write", perm: 0o777, groupAllowed: true, wantErr: true},
{name: "world write on a sticky directory", perm: 0o777, sticky: true},
{name: "group write on a sticky directory", perm: 0o775, sticky: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed)
if tt.wantErr {
assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
return
}
assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
})
}
}
// A build under a home directory on a distribution with a 002 umask, which is what
// a locally built or tarball-installed binary looks like. Its group has no members
// but its owner, so it is as good as owner-only.
//
// Whether this host is such a distribution is read from the environment rather than
// from groupWriteAllowed: asking the function under test whether to run would let
// it skip its own coverage away if it regressed to refusing everything.
func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) {
requirePrivatePrimaryGroup(t)
dir := ownerOnlyDir(t)
path := writeExecutable(t, dir)
require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable")
require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable")
err := checkOnlyOwnerWritable(path)
assert.NoError(t, err, "group write in the owner's own private group reaches nobody else")
}
// A group whose membership no source can answer for is treated as shared: the
// private-group allowance must not stand on a name nobody can vouch for. The
// membership listing itself lives in the getent package and is tested there.
func TestGroupHasOtherMembersRejectsAnUnknownGroup(t *testing.T) {
assert.True(t, groupHasOtherMembers("nonexistent_group_xyzzy_12345", "vma"),
"a group no source describes")
}
// A writable directory is as good as a writable file: whoever can write the
// directory can put a different binary at the same path.
func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) {
dir := filepath.Join(ownerOnlyDir(t), "bin")
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
path := writeExecutable(t, dir)
require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable")
assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused")
}
// A sticky world-writable directory is exempt: the sticky bit is what stops one
// user replacing another's entries. /tmp is why this matters.
func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) {
dir := filepath.Join(ownerOnlyDir(t), "sticky")
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
path := writeExecutable(t, dir)
require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable")
err := checkOnlyOwnerWritable(path)
assert.NoError(t, err, "the sticky bit stops another user replacing the executable")
}
func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) {
err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent"))
assert.Error(t, err, "an executable that is not there must be refused")
}
// requirePrivatePrimaryGroup skips unless this user's primary group is their own,
// which is what the user-private-group allowance is about.
func requirePrivatePrimaryGroup(t *testing.T) {
t.Helper()
self, err := user.Current()
require.NoError(t, err, "look up the test user")
group, err := user.LookupGroupId(strconv.Itoa(os.Getgid()))
require.NoError(t, err, "look up the test user's primary group")
if group.Name != self.Username {
t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name)
}
if groupHasOtherMembers(group.Name, self.Username) {
t.Skipf("group %q has other members, so it is not a private group", group.Name)
}
}
+215
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
}
@@ -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),
},
}
}
+29 -54
View File
@@ -60,7 +60,7 @@ import (
"github.com/netbirdio/netbird/client/internal/syncstore" "github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec" "github.com/netbirdio/netbird/client/jobexec"
"github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/client/netevents"
cProto "github.com/netbirdio/netbird/client/proto" cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns" nbdns "github.com/netbirdio/netbird/dns"
@@ -185,9 +185,9 @@ type EngineServices struct {
UpdateManager *updater.Manager UpdateManager *updater.Manager
ClientMetrics *metrics.ClientMetrics ClientMetrics *metrics.ClientMetrics
MetricsCtx context.Context 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. // 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. // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
@@ -211,9 +211,9 @@ type Engine struct {
config *EngineConfig config *EngineConfig
mobileDep MobileDependency 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. // availability; nil disables gating.
netState *netstate.State netMgr *netevents.Manager
// STUNs is a list of STUN servers used by ICE // STUNs is a list of STUN servers used by ICE
STUNs []*stun.URI STUNs []*stun.URI
@@ -350,7 +350,7 @@ func NewEngine(
syncMsgMux: &sync.Mutex{}, syncMsgMux: &sync.Mutex{},
config: config, config: config,
mobileDep: mobileDep, mobileDep: mobileDep,
netState: services.NetState, netMgr: services.NetMgr,
STUNs: []*stun.URI{}, STUNs: []*stun.URI{},
TURNs: []*stun.URI{}, TURNs: []*stun.URI{},
networkSerial: 0, networkSerial: 0,
@@ -882,8 +882,7 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
} }
// third, add the peer connections again // third, add the peer connections again
for _, p := range modified { for _, p := range modified {
err := e.addNewPeer(p) if err := e.addNewPeer(p); err != nil {
if err != nil {
return err return err
} }
} }
@@ -1510,8 +1509,12 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
return nil return nil
} }
if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, networkMap.GetPeerConfig().GetLazyConnectionEnabled()); err != nil { // Only update the flag when the sync carries a peer config; a nil peer config
log.Errorf("failed to update lazy connection feature flag: %v", err) // (e.g. a partial update) must not reset the cached flag to false.
if peerConfig := networkMap.GetPeerConfig(); peerConfig != nil {
if err := e.connMgr.UpdatedRemoteFeatureFlag(e.ctx, peerConfig.GetLazyConnectionEnabled()); err != nil {
log.Errorf("failed to update lazy connection feature flag: %v", err)
}
} }
if e.firewall != nil { if e.firewall != nil {
@@ -1577,8 +1580,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
// Ingress forward rules // Ingress forward rules
done = e.phase("forward_rules") done = e.phase("forward_rules")
forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules()) if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil {
if err != nil {
log.Errorf("failed to update forward rules, err: %v", err) log.Errorf("failed to update forward rules, err: %v", err)
} }
done() done()
@@ -1596,8 +1598,7 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
// must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
done = e.phase("lazy_exclude") done = e.phase("lazy_exclude")
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers) e.connMgr.SetExcludeList(e.ctx, e.toExcludedLazyPeers(remotePeers))
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
done() done()
e.networkSerial = serial e.networkSerial = serial
@@ -1846,15 +1847,15 @@ func addrToString(addr netip.Addr) string {
// addNewPeers adds peers that were not know before but arrived from the Management service with the update // addNewPeers adds peers that were not know before but arrived from the Management service with the update
func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error { func (e *Engine) addNewPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
for _, p := range peersUpdate { for _, p := range peersUpdate {
err := e.addNewPeer(p) if err := e.addNewPeer(p); err != nil {
if err != nil {
return err return err
} }
} }
return nil return nil
} }
// addNewPeer add peer if connection doesn't exist // addNewPeer add peer if connection doesn't exist. A peer that is not lazy by
// policy gets an always-active connection instead.
func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error { func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error {
peerKey := peerConfig.GetWgPubKey() peerKey := peerConfig.GetWgPubKey()
peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps())) peerIPs := make([]netip.Prefix, 0, len(peerConfig.GetAllowedIps()))
@@ -1889,7 +1890,8 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error {
log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err) log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err)
} }
if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn); exists { permanent := !e.connMgr.PeerLazyDefault(peerConfig.GetLazyState())
if exists := e.connMgr.AddPeerConn(e.ctx, peerKey, conn, permanent); exists {
conn.Close(false) conn.Close(false)
return fmt.Errorf("peer already exists: %s", peerKey) return fmt.Errorf("peer already exists: %s", peerKey)
} }
@@ -1922,8 +1924,8 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
Addr: e.getRosenpassAddr(), Addr: e.getRosenpassAddr(),
PermissiveMode: e.config.RosenpassPermissive, PermissiveMode: e.config.RosenpassPermissive,
}, },
ICEConfig: e.createICEConfig(), ICEConfig: e.createICEConfig(),
NetworkState: e.netState, NetMgr: e.netMgr,
} }
serviceDependencies := peer.ServiceDependencies{ serviceDependencies := peer.ServiceDependencies{
@@ -2681,46 +2683,19 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal
return forwardingRules, nberrors.FormatErrorOrNil(merr) return forwardingRules, nberrors.FormatErrorOrNil(merr)
} }
func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool { // toExcludedLazyPeers returns the peers that must have an always-active
// connection: those that are not lazy by policy (the per-peer lazy state or the
// account flag, subject to the local override).
func (e *Engine) toExcludedLazyPeers(peers []*mgmProto.RemotePeerConfig) map[string]bool {
excludedPeers := make(map[string]bool) excludedPeers := make(map[string]bool)
for _, p := range peers {
// Ingress forward targets: inbound forwarded traffic is initiated remotely and if !e.connMgr.PeerLazyDefault(p.GetLazyState()) {
// cannot wake a lazy connection, so the peer routing the target must stay excludedPeers[p.GetWgPubKey()] = true
// permanently connected. AllowedIPs are already parsed on the peer conn, so
// reuse those typed prefixes instead of re-parsing the network map strings.
for _, r := range rules {
for _, p := range peers {
if e.peerRoutesAddr(p, r.TranslatedAddress) {
log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey())
excludedPeers[p.GetWgPubKey()] = true
}
} }
} }
return excludedPeers return excludedPeers
} }
// peerRoutesAddr reports whether the peer is a router for addr, matched against
// the peer's already-parsed AllowedIPs from the store (the same typed value the
// lazy manager consumes) rather than re-parsing the network map strings.
func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool {
prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey())
if !ok {
return false
}
return prefixesContain(prefixes, addr)
}
// prefixesContain reports whether addr falls within any of the prefixes.
func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool {
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
// isChecksEqual checks if two slices of checks are equal. // isChecksEqual checks if two slices of checks are equal.
func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool { func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
normalize := func(checks []*mgmProto.Checks) []string { normalize := func(checks []*mgmProto.Checks) []string {
@@ -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
}
+1 -1
View File
@@ -519,7 +519,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
updateManager := update_channel.NewPeersUpdateManager(metrics) updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) 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) 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 { if err != nil {
return nil, "", err return nil, "", err
+2 -1
View File
@@ -279,7 +279,8 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
}, MobileDependency{}) }, MobileDependency{})
wgIface := &MockWGIface{ wgIface := &MockWGIface{
NameFunc: func() string { return "utun102" }, NameFunc: func() string { return "utun102" },
IsUserspaceBindFunc: func() bool { return true },
RemovePeerFunc: func(peerKey string) error { RemovePeerFunc: func(peerKey string) error {
return nil return nil
}, },
+36
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()
}
+6
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
@@ -1,4 +1,4 @@
package server package getent
import ( import (
"os/user" "os/user"
@@ -10,38 +10,48 @@ import (
"github.com/stretchr/testify/require" "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 // The current user should always be resolvable on any platform
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
u, err := lookupWithGetent(current.Username) u, err := LookupUser(current.Username)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, current.Username, u.Username) assert.Equal(t, current.Username, u.Username)
assert.Equal(t, current.Uid, u.Uid) assert.Equal(t, current.Uid, u.Uid)
assert.Equal(t, current.Gid, u.Gid) assert.Equal(t, current.Gid, u.Gid)
} }
func TestLookupWithGetent_NonexistentUser(t *testing.T) { func TestLookupUser_NonexistentUser(t *testing.T) {
_, err := lookupWithGetent("nonexistent_user_xyzzy_12345") _, err := LookupUser("nonexistent_user_xyzzy_12345")
require.Error(t, err, "should fail for nonexistent user") 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() stdUser, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
u, err := currentUserWithGetent() u, err := CurrentUser()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, stdUser.Uid, u.Uid) assert.Equal(t, stdUser.Uid, u.Uid)
assert.Equal(t, stdUser.Username, u.Username) assert.Equal(t, stdUser.Username, u.Username)
} }
func TestGroupIdsWithFallback_CurrentUser(t *testing.T) { func TestGroupIDs_CurrentUser(t *testing.T) {
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
groups, err := groupIdsWithFallback(current) groups, err := GroupIDs(current)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, groups, "current user should have at least one group") 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) { func TestUserShell_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
}
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
// getent may not be available on all systems (e.g., macOS without Homebrew getent) // getent may not be available on all systems (e.g., macOS without
shell := getShellFromGetent(current.Uid) // 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 == "" { if shell == "" {
t.Log("getShellFromGetent returned empty, getent may not be available") t.Log("UserShell returned empty, the user has no shell set")
return return
} }
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) 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" { if runtime.GOOS == "windows" {
t.Skip("no root user on Windows") t.Skip("no root user on Windows")
} }
u, err := lookupWithGetent("root") u, err := LookupUser("root")
if err != nil { if err != nil {
t.Skip("root user not available on this system") 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 // TestIntegration_FullLookupChain exercises the complete user lookup chain
// against the real system, testing that all wrappers (lookupWithGetent, // against the real system, testing that all wrappers (LookupUser,
// currentUserWithGetent, groupIdsWithFallback, getShellFromGetent) produce // CurrentUser, GroupIDs, UserShell) produce consistent and correct results
// consistent and correct results when composed together. // when composed together.
func TestIntegration_FullLookupChain(t *testing.T) { func TestIntegration_FullLookupChain(t *testing.T) {
// Step 1: currentUserWithGetent must resolve the running user. // Step 1: CurrentUser must resolve the running user.
current, err := currentUserWithGetent() current, err := CurrentUser()
require.NoError(t, err, "currentUserWithGetent must resolve the running user") require.NoError(t, err, "CurrentUser must resolve the running user")
require.NotEmpty(t, current.Uid) require.NotEmpty(t, current.Uid)
require.NotEmpty(t, current.Username) require.NotEmpty(t, current.Username)
// Step 2: lookupWithGetent by the same username must return matching identity. // Step 2: LookupUser by the same username must return matching identity.
byName, err := lookupWithGetent(current.Username) byName, err := LookupUser(current.Username)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID") 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.Gid, byName.Gid, "lookup by name should return same GID")
assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home") assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home")
// Step 3: groupIdsWithFallback must return at least the primary GID. // Step 3: GroupIDs must return at least the primary GID.
groups, err := groupIdsWithFallback(current) groups, err := GroupIDs(current)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, groups, "user must have at least one group") 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) 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 // TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via
// lookupWithGetent can have their groups resolved via groupIdsWithFallback, // LookupUser can have their groups resolved via GroupIDs, testing the handoff
// testing the handoff between the two functions as used by the SSH server. // between the two functions as used by the SSH server.
func TestIntegration_LookupAndGroupsConsistency(t *testing.T) { func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
// Simulate the SSH server flow: lookup user, then get their groups. // 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) require.NoError(t, err)
groups, err := groupIdsWithFallback(resolved) groups, err := GroupIDs(resolved)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, groups, "resolved user must have groups") require.NotEmpty(t, groups, "resolved user must have groups")
@@ -154,19 +153,3 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
} }
} }
} }
// TestIntegration_ShellLookupChain tests the full shell resolution chain
// (getShellFromPasswd -> getShellFromGetent -> $SHELL -> default) on Unix.
func TestIntegration_ShellLookupChain(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix shell lookup not applicable on Windows")
}
current, err := user.Current()
require.NoError(t, err)
// getUserShell is the top-level function used by the SSH server.
shell := getUserShell(current.Uid)
require.NotEmpty(t, shell, "getUserShell must always return a shell")
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}
+110
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
}
+224
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
}
@@ -1,10 +1,12 @@
//go:build !windows //go:build !windows
package server package getent
import ( import (
"os"
"os/exec" "os/exec"
"os/user" "os/user"
"path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"testing" "testing"
@@ -13,7 +15,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestParseGetentPasswd(t *testing.T) { func TestParsePasswd(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
@@ -128,7 +130,7 @@ func TestParseGetentPasswd(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
u, shell, err := parseGetentPasswd(tt.input) u, shell, err := parsePasswd(tt.input)
if tt.wantErr { if tt.wantErr {
require.Error(t, err) require.Error(t, err)
if tt.errContains != "" { 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 { tests := []struct {
name string name string
input string input string
@@ -180,7 +295,7 @@ func TestValidateGetentInput(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) return string(b)
} }
func TestRunGetent_RootUser(t *testing.T) { func TestPasswdLookup_RootUser(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil { if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system") t.Skip("getent not available on this system")
} }
u, shell, err := runGetent("root") u, shell, err := passwdLookup("root")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "root", u.Username) assert.Equal(t, "root", u.Username)
assert.Equal(t, "0", u.Uid) 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") 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 { if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system") t.Skip("getent not available on this system")
} }
u, _, err := runGetent("0") u, _, err := passwdLookup("0")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "root", u.Username) assert.Equal(t, "root", u.Username)
assert.Equal(t, "0", u.Uid) 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 { if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system") t.Skip("getent not available on this system")
} }
_, _, err := runGetent("nonexistent_user_xyzzy_12345") _, _, err := passwdLookup("nonexistent_user_xyzzy_12345")
assert.Error(t, err) assert.Error(t, err)
} }
func TestRunGetent_InvalidInput(t *testing.T) { func TestPasswdLookup_InvalidInput(t *testing.T) {
_, _, err := runGetent("") _, _, err := passwdLookup("")
assert.Error(t, err) assert.Error(t, err)
_, _, err = runGetent("user\x00name") _, _, err = passwdLookup("user\x00name")
assert.Error(t, err) assert.Error(t, err)
} }
func TestRunGetent_NotAvailable(t *testing.T) { func TestPasswdLookup_NotAvailable(t *testing.T) {
if _, err := exec.LookPath("getent"); err == nil { if _, err := exec.LookPath("getent"); err == nil {
t.Skip("getent is available, can't test missing case") 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") 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 { if _, err := exec.LookPath("id"); err != nil {
t.Skip("id not available on this system") t.Skip("id not available on this system")
} }
@@ -251,7 +377,7 @@ func TestRunIdGroups_CurrentUser(t *testing.T) {
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
groups, err := runIdGroups(current.Username) groups, err := idGroups(current.Username)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, groups, "current user should have at least one group") 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 { if _, err := exec.LookPath("id"); err != nil {
t.Skip("id not available on this system") t.Skip("id not available on this system")
} }
_, err := runIdGroups("nonexistent_user_xyzzy_12345") _, err := idGroups("nonexistent_user_xyzzy_12345")
assert.Error(t, err) assert.Error(t, err)
} }
func TestRunIdGroups_InvalidInput(t *testing.T) { func TestIdGroups_InvalidInput(t *testing.T) {
_, err := runIdGroups("") _, err := idGroups("")
assert.Error(t, err) assert.Error(t, err)
_, err = runIdGroups("user\x00name") _, err = idGroups("user\x00name")
assert.Error(t, err) assert.Error(t, err)
} }
@@ -286,7 +412,7 @@ func TestGetentResultsMatchStdlib(t *testing.T) {
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
getentUser, _, err := runGetent(current.Username) getentUser, _, err := passwdLookup(current.Username)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, current.Username, getentUser.Username, "username should match") assert.Equal(t, current.Username, getentUser.Username, "username should match")
@@ -303,7 +429,7 @@ func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
current, err := user.Current() current, err := user.Current()
require.NoError(t, err) require.NoError(t, err)
getentUser, _, err := runGetent(current.Uid) getentUser, _, err := passwdLookup(current.Uid)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID") 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") 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) require.NoError(t, err)
// Deduplicate both lists: id -G can return duplicates (e.g., root in Docker) // Deduplicate both lists: id -G can return duplicates (e.g., root in Docker)
// and ElementsMatch treats duplicates as distinct. // 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 { func uniqueStrings(ss []string) []string {
@@ -343,71 +469,3 @@ func uniqueStrings(ss []string) []string {
} }
return out return out
} }
// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly
// reads the current user's shell from /etc/passwd by comparing it against what
// getent reports (which goes through NSS).
func TestGetShellFromPasswd_CurrentUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
shell := getShellFromPasswd(current.Uid)
if shell == "" {
t.Skip("current user not found in /etc/passwd (may be an NSS-only user)")
}
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
if _, err := exec.LookPath("getent"); err == nil {
_, getentShell, getentErr := runGetent(current.Uid)
if getentErr == nil && getentShell != "" {
assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent")
}
}
}
// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read
// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on
// any standard Unix system.
func TestGetShellFromPasswd_RootUser(t *testing.T) {
shell := getShellFromPasswd("0")
require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd")
assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell)
}
// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd
// returns empty for a UID that doesn't exist in /etc/passwd.
func TestGetShellFromPasswd_NonexistentUID(t *testing.T) {
shell := getShellFromPasswd("4294967294")
assert.Empty(t, shell, "nonexistent UID should return empty shell")
}
// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly
// and cross-validates every entry against getent to ensure parseGetentPasswd
// and getShellFromPasswd agree on shell values.
func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available")
}
// Pick a few well-known system UIDs that are virtually always in /etc/passwd.
uids := []string{"0"} // root
current, err := user.Current()
require.NoError(t, err)
uids = append(uids, current.Uid)
for _, uid := range uids {
passwdShell := getShellFromPasswd(uid)
if passwdShell == "" {
continue
}
_, getentShell, err := runGetent(uid)
if err != nil {
continue
}
assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid)
}
}
+36
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
}
+16
View File
@@ -91,6 +91,12 @@ func SelfDelegatesTo() (Identity, bool) {
return selfIdentity, true return selfIdentity, true
} }
// The values PrivilegedActorKey returns.
const (
ActorKeyAdministrator = "administrator"
ActorKeyRoot = "root"
)
// PrivilegedActor names the principal a privileged operation requires, for use // PrivilegedActor names the principal a privileged operation requires, for use
// in messages shown to the user. // in messages shown to the user.
func PrivilegedActor() string { func PrivilegedActor() string {
@@ -100,6 +106,16 @@ func PrivilegedActor() string {
return "root" 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 // 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 // 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 // returned unchanged and the user is expected to run it from an elevated
@@ -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)
}
@@ -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)
})
}
}
+3 -20
View File
@@ -45,30 +45,13 @@ func (m *influxDBMetrics) RecordConnectionStages(
isReconnection bool, isReconnection bool,
timestamps ConnectionStageTimestamps, timestamps ConnectionStageTimestamps,
) { ) {
var signalingReceivedToConnection, connectionToWgHandshake, totalDuration float64 signalingReceivedToConnection, connectionToWgHandshake, totalDuration := timestamps.Durations()
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"
}
connTypeStr := connectionType.String() 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", 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(), agentInfo.DeploymentType.String(),
connTypeStr, connTypeStr,
attemptType, attemptType(isReconnection),
agentInfo.Version, agentInfo.Version,
agentInfo.OS, agentInfo.OS,
agentInfo.Arch, agentInfo.Arch,
@@ -94,7 +77,7 @@ func (m *influxDBMetrics) RecordConnectionStages(
m.trimLocked() m.trimLocked()
log.Tracef("peer connection metrics [%s, %s, %s]: signalingReceived→connection: %.3fs, connection→wg_handshake: %.3fs, total: %.3fs", 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) { func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) {
+23
View File
@@ -109,6 +109,21 @@ type ConnectionStageTimestamps struct {
WgHandshakeSuccess time.Time 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 // String returns a human-readable representation of the connection stage timestamps
func (c ConnectionStageTimestamps) String() string { func (c ConnectionStageTimestamps) String() string {
return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}", return fmt.Sprintf("ConnectionStageTimestamps{SignalingReceived=%v, ConnectionReady=%v, WgHandshakeSuccess=%v}",
@@ -311,3 +326,11 @@ func (c *ClientMetrics) stopPushLocked() {
c.wg.Wait() c.wg.Wait()
c.push.Store(nil) 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"
}
+15 -1
View File
@@ -2,10 +2,24 @@
package metrics package metrics
import "github.com/prometheus/client_golang/prometheus"
// NewClientMetrics creates a new ClientMetrics instance // NewClientMetrics creates a new ClientMetrics instance
func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics { func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics {
return &ClientMetrics{ return &ClientMetrics{
impl: newInfluxDBMetrics(), impl: newPrometheusMetrics(newInfluxDBMetrics()),
agentInfo: agentInfo, 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
}
+125
View File
@@ -0,0 +1,125 @@
//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)
}
// RecordVNCSessionTick implements metricsImplementation by delegating to the
// wrapped implementation; there is no local Prometheus series for VNC sessions.
func (m *prometheusMetrics) RecordVNCSessionTick(ctx context.Context, agentInfo AgentInfo, tick VNCSessionTick) {
m.next.RecordVNCSessionTick(ctx, agentInfo, tick)
}
// 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()
}
+4 -4
View File
@@ -26,7 +26,7 @@ import (
"github.com/netbirdio/netbird/client/internal/portforward" "github.com/netbirdio/netbird/client/internal/portforward"
"github.com/netbirdio/netbird/client/internal/rosenpass" "github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/client/netevents"
"github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/route"
relayClient "github.com/netbirdio/netbird/shared/relay/client" relayClient "github.com/netbirdio/netbird/shared/relay/client"
) )
@@ -95,9 +95,9 @@ type ConnConfig struct {
// ICEConfig ICE protocol configuration // ICEConfig ICE protocol configuration
ICEConfig icemaker.Config 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. // availability; nil disables gating.
NetworkState *netstate.State NetMgr *netevents.Manager
} }
type Conn struct { type Conn struct {
@@ -259,7 +259,7 @@ func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer) 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) conn.wg.Add(1)
go func() { go func() {
+18 -11
View File
@@ -6,8 +6,6 @@ import (
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/netstate"
) )
// ConnStatus represents the connection state as seen by the guard. // ConnStatus represents the connection state as seen by the guard.
@@ -24,6 +22,12 @@ const (
type connStatusFunc func() ConnStatus 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. // Guard is responsible for the reconnection logic.
// It will trigger to send an offer to the peer then has connection issues. // It will trigger to send an offer to the peer then has connection issues.
// Watch these events: // Watch these events:
@@ -37,22 +41,22 @@ type Guard struct {
isConnectedOnAllWay connStatusFunc isConnectedOnAllWay connStatusFunc
timeout time.Duration timeout time.Duration
srWatcher *SRWatcher srWatcher *SRWatcher
// netState gates reconnect attempts on OS-reported network availability; // netWatcher gates reconnect attempts on OS-reported network availability;
// nil disables gating. // nil disables gating.
netState *netstate.State netWatcher NetworkWatcher
relayedConnDisconnected chan struct{} relayedConnDisconnected chan struct{}
iCEConnDisconnected 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. // 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{ return &Guard{
log: log, log: log,
isConnectedOnAllWay: isConnectedFn, isConnectedOnAllWay: isConnectedFn,
timeout: timeout, timeout: timeout,
srWatcher: srWatcher, srWatcher: srWatcher,
netState: netState, netWatcher: netWatcher,
relayedConnDisconnected: make(chan struct{}, 1), relayedConnDisconnected: make(chan struct{}, 1),
iCEConnDisconnected: 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} iceState := &iceRetryState{log: g.log}
defer iceState.reset() defer iceState.reset()
netChanged := g.netState.Changed() var netChanged <-chan struct{}
if g.netWatcher != nil {
netChanged = g.netWatcher.Changed()
}
for { for {
select { select {
case <-tickerChannel: case <-tickerChannel:
// skip attempts while the OS reports no usable network; the // skip attempts while the OS reports no usable network; the
// netChanged case below resumes the loop once it returns // netChanged case below resumes the loop once it returns
if !g.netState.IsOnline() { if g.netWatcher != nil && !g.netWatcher.IsOnline() {
continue continue
} }
switch g.isConnectedOnAllWay() { switch g.isConnectedOnAllWay() {
@@ -152,8 +159,8 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) {
case <-netChanged: case <-netChanged:
// Re-arm for the next transition before acting on this one. // Re-arm for the next transition before acting on this one.
netChanged = g.netState.Changed() netChanged = g.netWatcher.Changed()
if !g.netState.IsOnline() { if !g.netWatcher.IsOnline() {
continue continue
} }
// Ticks skipped while offline drove the backoff towards its // Ticks skipped while offline drove the backoff towards its
@@ -9,7 +9,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/peer/ice" "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 // newTestGuardWithNetState builds a guard with a realistic MaxInterval: the
+12
View File
@@ -1167,6 +1167,18 @@ func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo
return maps.Clone(d.resolvedDomainsStates) 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 // GetFullStatus gets full status
func (d *Status) GetFullStatus() FullStatus { func (d *Status) GetFullStatus() FullStatus {
fullStatus := FullStatus{ fullStatus := FullStatus{
+22
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") 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) { func TestStatus_UpdatePeerFQDN(t *testing.T) {
key := "abc" key := "abc"
fqdn := "peer-a.netbird.local" fqdn := "peer-a.netbird.local"
+26
View File
@@ -105,6 +105,9 @@ type ConfigInput struct {
DNSLabels domain.List DNSLabels domain.List
MTU *uint16 MTU *uint16
LocalMetricsEnabled *bool
LocalMetricsAddress *string
} }
// Config Configuration type // Config Configuration type
@@ -148,6 +151,11 @@ type Config struct {
DNSLabels domain.List 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 is a private SSH key in a PEM format
SSHKey string SSHKey string
@@ -392,6 +400,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true 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) { if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) {
log.Infof("switching Network Monitor to %t", *input.NetworkMonitor) log.Infof("switching Network Monitor to %t", *input.NetworkMonitor)
config.NetworkMonitor = input.NetworkMonitor config.NetworkMonitor = input.NetworkMonitor
@@ -751,6 +771,12 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v }) applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v }) applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = 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 { if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
// REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the // REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
@@ -160,6 +160,32 @@ func TestApply_MDMVNCKeys(t *testing.T) {
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableVNCApproval)) assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableVNCApproval))
} }
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) { func TestApply_MDMLazyConnection(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
+4
View File
@@ -109,6 +109,10 @@
// - Does NOT remove result.json (cleaned by ResultHandler after read) // - Does NOT remove result.json (cleaned by ResultHandler after read)
// - Does NOT remove msi.log (kept for debugging) // - 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
// //
// Dry-run mode allows testing the update process without actually installing: // Dry-run mode allows testing the update process without actually installing:
@@ -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)
}
}
@@ -152,8 +152,8 @@ func (u *Installer) CleanUpInstallerFiles() error {
var merr *multierror.Error var merr *multierror.Error
if err := os.Remove(filepath.Join(u.tempDir, updaterBinary)); err != nil && !os.IsNotExist(err) { if err := removeUpdaterBinary(filepath.Join(u.tempDir, updaterBinary)); err != nil {
merr = multierror.Append(merr, fmt.Errorf("failed to remove updater binary: %w", err)) merr = multierror.Append(merr, fmt.Errorf("remove updater binary: %w", err))
} }
entries, err := os.ReadDir(u.tempDir) entries, err := os.ReadDir(u.tempDir)
@@ -167,10 +167,16 @@ func (u *Installer) CleanUpInstallerFiles() error {
} }
name := entry.Name() 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 { for _, ext := range binaryExtensions {
if strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) { if strings.HasSuffix(strings.ToLower(name), strings.ToLower(ext)) {
if err := os.Remove(filepath.Join(u.tempDir, name)); err != nil { 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 break
} }
@@ -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)
}
}
@@ -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
}
@@ -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)
}
@@ -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)
}
}
+14 -17
View File
@@ -22,8 +22,7 @@ import (
"github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/client/netevents"
"github.com/netbirdio/netbird/client/netsweep"
"github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/route"
@@ -84,12 +83,10 @@ type Client struct {
onHostDnsFn func([]string) onHostDnsFn func([]string)
dnsManager dns.IosDnsManager dnsManager dns.IosDnsManager
loginComplete bool loginComplete bool
// netState outlives engine restarts: it mirrors the OS connectivity, not // netMgr outlives engine restarts: it mirrors the OS connectivity, not
// the engine lifecycle. Run injects it into each new ConnectClient, which // the engine lifecycle. Run injects its state and sweeper into each new
// distributes it to every reconnection loop. // ConnectClient.
netState *netstate.State netMgr *netevents.Manager
// sweeper also outlives engine restarts; NotifyNetworkChange sweeps it.
sweeper *netsweep.Sweeper
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
preloadedConfig *profilemanager.Config preloadedConfig *profilemanager.Config
@@ -100,6 +97,7 @@ type Client struct {
// NewClient instantiate a new Client // NewClient instantiate a new Client
func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client { func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client {
recorder := peer.NewRecorder("")
return &Client{ return &Client{
cfgFile: cfgFile, cfgFile: cfgFile,
stateFile: stateFile, stateFile: stateFile,
@@ -108,12 +106,11 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV
deviceName: deviceName, deviceName: deviceName,
osName: osName, osName: osName,
osVersion: osVersion, osVersion: osVersion,
recorder: peer.NewRecorder(""), recorder: recorder,
ctxCancelLock: &sync.Mutex{}, ctxCancelLock: &sync.Mutex{},
networkChangeListener: networkChangeListener, networkChangeListener: networkChangeListener,
dnsManager: dnsManager, dnsManager: dnsManager,
netState: netstate.New(), netMgr: netevents.NewManager(recorder),
sweeper: netsweep.New(),
} }
} }
@@ -190,7 +187,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
cfg.WgIface = interfaceName cfg.WgIface = interfaceName
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, connectClient := internal.NewConnectClient(ctx, cfg, c.recorder,
internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) internal.WithNetEvents(c.netMgr))
c.setState(cfg, connectClient) c.setState(cfg, connectClient)
// Persist the latest sync response so DebugBundle can include the network // 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 // map. On iOS this is backed by disk to keep it out of the constrained
@@ -203,10 +200,11 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
// (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops // (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops
// suspend their attempts and the connection listener reports NoNetwork // suspend their attempts and the connection listener reports NoNetwork
// instead of Connecting; when availability returns, the loops resume // 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) { func (c *Client) SetNetworkAvailable(available bool) {
c.netState.Set(available) c.netMgr.SetNetworkAvailable(available)
c.recorder.SetNetworkAvailable(available)
} }
// NotifyNetworkChange marks the management, signal and relay connections // NotifyNetworkChange marks the management, signal and relay connections
@@ -214,8 +212,7 @@ func (c *Client) SetNetworkAvailable(available bool) {
// whatever has not redialed on the new network by then. The engine and the // whatever has not redialed on the new network by then. The engine and the
// TUN device stay untouched. // TUN device stay untouched.
func (c *Client) NotifyNetworkChange() { func (c *Client) NotifyNetworkChange() {
c.sweeper.MarkNetworkChange() c.netMgr.NotifyNetworkChange()
log.Infof("network change: connections marked stale")
} }
// Stop the internal client and free the resources // Stop the internal client and free the resources
+138
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}
}
+2
View File
@@ -29,6 +29,8 @@ var allKeys = []string{
KeyRosenpassEnabled, KeyRosenpassEnabled,
KeyRosenpassPermissive, KeyRosenpassPermissive,
KeyWireguardPort, KeyWireguardPort,
KeyEnableLocalMetrics,
KeyLocalMetricsAddress,
KeySplitTunnelMode, KeySplitTunnelMode,
KeySplitTunnelApps, KeySplitTunnelApps,
KeyLazyConnection, KeyLazyConnection,
+52
View File
@@ -0,0 +1,52 @@
//go:build windows || darwin
package mdm
import (
"go/ast"
"go/parser"
"go/token"
"slices"
"strconv"
"testing"
)
// TestAllKeysCoversEveryPolicyKey guards against the drift that adding a Key*
// constant without listing it in allKeys causes: the desktop loaders resolve
// value names through canonicalKey, so an unlisted key is silently discarded as
// unknown. policy.go is parsed rather than hand-mirrored so the test cannot go
// stale in the same way.
func TestAllKeysCoversEveryPolicyKey(t *testing.T) {
file, err := parser.ParseFile(token.NewFileSet(), "policy.go", nil, 0)
if err != nil {
t.Fatalf("parse policy.go: %v", err)
}
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.CONST {
continue
}
for _, spec := range gen.Specs {
value, ok := spec.(*ast.ValueSpec)
if !ok || len(value.Names) != 1 || len(value.Values) != 1 {
continue
}
name := value.Names[0].Name
if len(name) < 4 || name[:3] != "Key" {
continue
}
lit, ok := value.Values[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
key, err := strconv.Unquote(lit.Value)
if err != nil {
t.Fatalf("unquote %s: %v", name, err)
}
if !slices.Contains(allKeys, key) {
t.Errorf("%s (%q) is missing from allKeys, so the desktop loaders discard it as unknown", name, key)
}
}
}
}
+2
View File
@@ -49,6 +49,8 @@ const (
KeyRosenpassEnabled = "rosenpassEnabled" KeyRosenpassEnabled = "rosenpassEnabled"
KeyRosenpassPermissive = "rosenpassPermissive" KeyRosenpassPermissive = "rosenpassPermissive"
KeyWireguardPort = "wireguardPort" KeyWireguardPort = "wireguardPort"
KeyEnableLocalMetrics = "enableLocalMetrics"
KeyLocalMetricsAddress = "localMetricsAddress"
// Split tunnel is modeled as a single conceptual policy with two // Split tunnel is modeled as a single conceptual policy with two
// registry/plist values. KeySplitTunnelMode is the discriminator // registry/plist values. KeySplitTunnelMode is the discriminator
+294
View File
@@ -0,0 +1,294 @@
// Package mobile holds the profile manager implementation shared by the
// Android and iOS gomobile bindings. The platform packages (client/android,
// client/ios/NetBirdSDK) only adapt this API to gomobile-friendly types.
package mobile
import (
"fmt"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
const (
// Config filename of the default profile, stored at the configDir root.
// Both platforms use netbird.cfg (matching the desktop netbird.cfg rather
// than default.json); the app-side path constants must match.
defaultConfigFilename = "netbird.cfg"
// Subdirectory of configDir holding non-default profiles.
profilesSubdir = "profiles"
)
/*
<configDir>/ ← app-writable config root
├── netbird.cfg ← Default profile config
├── netbird.account.json ← Default profile account email (see profile_state.go)
├── state.json ← Default profile state
├── active_profile.json ← Active profile tracker (JSON with ID + Username)
└── profiles/ ← Subdirectory for non-default profiles
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← Profile config (filename = ID)
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← Profile state
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.account.json ← Profile account email
└── 4c5f5c8198c3989cffb5b5394f5a7ae0.prefs.json ← Profile preferences
*/
// Profile is the platform-independent profile view handed to the bindings.
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.
Email string
IsActive bool
}
// ProfileManager manages profiles for the mobile platforms. It wraps the
// internal profilemanager.ServiceManager with mobile-specific path handling.
// All profile identity is ID-based; the human-readable name lives inside the
// profile config's Name field.
type ProfileManager struct {
configDir string
username string
serviceMgr *profilemanager.ServiceManager
}
// NewProfileManager creates a profile manager rooted at configDir, the
// app-writable directory that every process of the app can reach. username is
// the platform's fixed single-user context (a non-empty username is required
// by ServiceManager for non-default profiles).
func NewProfileManager(configDir, username string) *ProfileManager {
// The default profile is stored in the root configDir, not under profiles/.
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
// Point the package globals at the app-provided directory, overriding the
// desktop defaults set in profilemanager's init().
profilemanager.DefaultConfigPathDir = configDir
profilemanager.DefaultConfigPath = defaultConfigPath
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
// Non-default profiles live in the profiles/ subdirectory. Passing it
// explicitly avoids touching the global config-dir override.
profilesDir := filepath.Join(configDir, profilesSubdir)
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
return &ProfileManager{
configDir: configDir,
username: username,
serviceMgr: serviceMgr,
}
}
// ListProfiles returns all available profiles, including the default profile,
// with their active status set.
func (pm *ProfileManager) ListProfiles() ([]Profile, error) {
internalProfiles, err := pm.serviceMgr.ListProfiles(pm.username)
if err != nil {
return nil, fmt.Errorf("list profiles: %w", err)
}
profiles := make([]Profile, 0, len(internalProfiles))
for _, p := range internalProfiles {
profiles = append(profiles, Profile{
ID: p.ID.String(),
Name: p.Name,
Email: pm.profileEmail(p.ID.String()),
IsActive: p.IsActive,
})
}
return profiles, nil
}
// GetActiveProfile returns the currently active profile, resolving its ID to
// the full profile so callers get the real display name.
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
activeState, err := pm.serviceMgr.GetActiveProfileState()
if err != nil {
return nil, fmt.Errorf("get active profile: %w", err)
}
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), pm.username)
if err != nil {
return nil, fmt.Errorf("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
}
// 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 {
if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: profilemanager.ID(id),
Username: pm.username,
}); err != nil {
return fmt.Errorf("switch profile: %w", err)
}
log.Infof("switched to profile: %s", id)
return nil
}
// 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) {
profile, err := pm.serviceMgr.AddProfile(displayName, pm.username)
if err != nil {
return nil, fmt.Errorf("add profile: %w", err)
}
log.Infof("created new profile: %s", profile.ID)
return &Profile{ID: profile.ID.String(), Name: profile.Name, IsActive: false}, 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 {
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil {
return fmt.Errorf("rename profile: %w", err)
}
log.Infof("renamed profile %s to %q", id, newName)
return nil
}
// LogoutProfile clears authentication data for a profile by removing its
// private key and SSH key from the config, forcing a re-login. The management
// URL and other settings are preserved.
func (pm *ProfileManager) LogoutProfile(id string) error {
configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return fmt.Errorf("profile %q does not exist", id)
}
config, err := profilemanager.ReadConfig(configPath)
if err != nil {
return fmt.Errorf("read profile config: %w", err)
}
config.PrivateKey = ""
config.SSHKey = ""
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
return fmt.Errorf("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
}
// 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
}
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), pm.username); err != nil {
return fmt.Errorf("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
}
// ProfilePrefs returns the namespaced per-profile preference store of the
// profile identified by id.
func (pm *ProfileManager) ProfilePrefs(id string) (*profilemanager.Prefs, error) {
prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), pm.username)
if err != nil {
return nil, fmt.Errorf("resolve profile prefs: %w", err)
}
return prefs, nil
}
// GetConfigPath returns the config file path for the given profile ID. The
// platform code should call this instead of constructing paths itself.
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
return pm.getProfileConfigPath(id)
}
// GetStateFilePath returns the state file path for the given profile ID.
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
}
// 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("get active profile: %w", err)
}
return pm.GetConfigPath(activeProfile.ID)
}
// 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("get active profile: %w", err)
}
return pm.GetStateFilePath(activeProfile.ID)
}
// 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)
}
// getProfileConfigPath returns the config file path for a profile ID. The
// default profile uses netbird.cfg in the root configDir; other profiles use
// <id>.json in the profiles/ subdirectory.
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 {
return filepath.Join(pm.configDir, defaultConfigFilename), nil
}
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
return filepath.Join(profilesDir, id+".json"), nil
}
@@ -1,4 +1,4 @@
package android package mobile
import ( import (
"context" "context"
@@ -14,17 +14,13 @@ import (
) )
const ( const (
// Android-specific config filename (different from desktop default.json)
defaultConfigFilename = "netbird.cfg"
// Subdirectory for non-default profiles (must match Java Preferences.java)
profilesSubdir = "profiles"
// profileAccountSuffix names the file holding the profile's account email. // profileAccountSuffix names the file holding the profile's account email.
// Deliberately not ".state.json", which desktop uses for the same data: // Deliberately not ".state.json", which desktop uses for the same data:
// there the email and the engine's state manager live in different // there the email and the engine's state manager live in different
// directories, but on Android both resolve under files/, so sharing the name // directories, but on mobile both resolve under configDir, so sharing the
// would have the two overwrite each other — the state manager rewrites the // name would have the two overwrite each other — the state manager rewrites
// whole file from its own keys (see statemanager.Manager.PersistState), and // the whole file from its own keys (see statemanager.Manager.PersistState),
// this package's writer does the same in reverse. // and this package's writer does the same in reverse.
profileAccountSuffix = ".account.json" profileAccountSuffix = ".account.json"
) )
@@ -32,7 +28,7 @@ const (
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json. // path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
// //
// Deriving from the config path rather than resolving the active profile keeps // Deriving from the config path rather than resolving the active profile keeps
// the write on the profile the login actually ran for: Auth.login runs in a // the write on the profile the login actually ran for: login flows run in a
// goroutine, so the active profile can change under a flow already in flight. // goroutine, so the active profile can change under a flow already in flight.
func profileAccountPathFor(configPath string) (string, error) { func profileAccountPathFor(configPath string) (string, error) {
if configPath == "" { if configPath == "" {
@@ -48,10 +44,10 @@ func profileAccountPathFor(configPath string) (string, error) {
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
} }
// readProfileEmail returns the account email stored for the profile whose config // ReadProfileEmail returns the account email stored for the profile whose
// lives at configPath. A missing or unreadable file yields "", which leaves the // config lives at configPath. A missing or unreadable file yields "", which
// account choice to the IdP. // leaves the account choice to the IdP.
func readProfileEmail(configPath string) string { func ReadProfileEmail(configPath string) string {
accountPath, err := profileAccountPathFor(configPath) accountPath, err := profileAccountPathFor(configPath)
if err != nil { if err != nil {
log.Debugf("no profile account path for login hint: %v", err) log.Debugf("no profile account path for login hint: %v", err)
@@ -69,10 +65,10 @@ func readProfileEmail(configPath string) string {
return state.Email return state.Email
} }
// writeProfileEmail records the account email for the profile whose config lives // WriteProfileEmail records the account email for the profile whose config
// at configPath, so later logins can pass it as an OIDC login_hint. An empty // lives at configPath, so later logins can pass it as an OIDC login_hint. An
// email is ignored rather than blanking what is already stored. // empty email is ignored rather than blanking what is already stored.
func writeProfileEmail(configPath string, email string) error { func WriteProfileEmail(configPath string, email string) error {
if email == "" { if email == "" {
return nil return nil
} }
@@ -1,4 +1,4 @@
package android package mobile
import ( import (
"os" "os"
@@ -15,18 +15,18 @@ func TestProfileAccountPathFor(t *testing.T) {
}{ }{
{ {
name: "default profile", name: "default profile",
configPath: "/data/data/io.netbird.client/files/netbird.cfg", configPath: "/data/netbird/files/netbird.cfg",
want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"), want: filepath.FromSlash("/data/netbird/files/netbird.account.json"),
}, },
{ {
name: "id profile", name: "id profile",
configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json", configPath: "/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"), want: filepath.FromSlash("/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
}, },
{ {
name: "legacy name-keyed profile is handled the same way", name: "legacy name-keyed profile is handled the same way",
configPath: "/data/data/io.netbird.client/files/profiles/work.json", configPath: "/data/netbird/files/profiles/work.json",
want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"), want: filepath.FromSlash("/data/netbird/files/profiles/work.account.json"),
}, },
{ {
name: "empty path is rejected", name: "empty path is rejected",
@@ -55,7 +55,7 @@ func TestProfileAccountPathFor(t *testing.T) {
} }
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) { func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
root := "/data/data/io.netbird.client/files" root := "/data/netbird/files"
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename)) defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
if err != nil { if err != nil {
@@ -72,12 +72,12 @@ func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
} }
} }
// The account file must never land on the engine state file: on Android both // The account file must never land on the engine state file: on mobile both
// resolve under files/, and the state manager rewrites the whole file from its // resolve under configDir, and the state manager rewrites the whole file from
// own keys, so sharing a path would have the two overwrite each other. The // its own keys, so sharing a path would have the two overwrite each other. The
// expected names here mirror ProfileManager.GetStateFilePath. // expected names here mirror ProfileManager.GetStateFilePath.
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) { func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
root := "/data/data/io.netbird.client/files" root := "/data/netbird/files"
cases := []struct { cases := []struct {
configPath string configPath string
@@ -110,23 +110,23 @@ func TestWriteThenReadProfileEmail(t *testing.T) {
t.Fatalf("prepare dir: %v", err) t.Fatalf("prepare dir: %v", err)
} }
if got := readProfileEmail(configPath); got != "" { if got := ReadProfileEmail(configPath); got != "" {
t.Errorf("expected no email before a login, got %q", got) t.Errorf("expected no email before a login, got %q", got)
} }
const email = "user@example.com" const email = "user@example.com"
if err := writeProfileEmail(configPath, email); err != nil { if err := WriteProfileEmail(configPath, email); err != nil {
t.Fatalf("write: %v", err) t.Fatalf("write: %v", err)
} }
if got := readProfileEmail(configPath); got != email { if got := ReadProfileEmail(configPath); got != email {
t.Errorf("got %q, want %q", got, email) t.Errorf("got %q, want %q", got, email)
} }
if err := removeProfileEmail(configPath); err != nil { if err := removeProfileEmail(configPath); err != nil {
t.Fatalf("remove: %v", err) t.Fatalf("remove: %v", err)
} }
if got := readProfileEmail(configPath); got != "" { if got := ReadProfileEmail(configPath); got != "" {
t.Errorf("expected no email after removal, got %q", got) t.Errorf("expected no email after removal, got %q", got)
} }
@@ -143,14 +143,14 @@ func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
} }
const email = "user@example.com" const email = "user@example.com"
if err := writeProfileEmail(configPath, email); err != nil { if err := WriteProfileEmail(configPath, email); err != nil {
t.Fatalf("write: %v", err) t.Fatalf("write: %v", err)
} }
if err := writeProfileEmail(configPath, ""); err != nil { if err := WriteProfileEmail(configPath, ""); err != nil {
t.Fatalf("write empty: %v", err) t.Fatalf("write empty: %v", err)
} }
if got := readProfileEmail(configPath); got != email { if got := ReadProfileEmail(configPath); got != email {
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email) t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
} }
} }
+110
View File
@@ -0,0 +1,110 @@
package net
import (
"fmt"
"os"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
)
const (
// envFwmarkBase overrides the base of the fwmark range. Container network
// plugins, CNIs and other VPNs claim bits of the mark space for themselves,
// and a rule of theirs matching one of our bits acts on our traffic, so
// hosts running such software may need to move the range out of the way.
envFwmarkBase = "NB_FWMARK_BASE"
// defaultFwmarkBase is the base of the fwmark range used when the
// environment does not override it.
defaultFwmarkBase uint32 = 0x1BD00
// fwmarkOffsetMask is the part of a mark that identifies the individual mark
// within the range, so the base occupies everything above it.
fwmarkOffsetMask uint32 = 0xFF
)
// Offsets of the individual marks within the range.
const (
offsetControlPlane uint32 = 0x00
offsetDataPlaneIn uint32 = 0x10
offsetDataPlaneOut uint32 = 0x11
offsetRedirected uint32 = 0x20
offsetMasquerade uint32 = 0x21
offsetMasqueradeReturn uint32 = 0x22
offsetDataPlaneLower uint32 = 0x10
offsetDataPlaneUpper uint32 = fwmarkOffsetMask
)
var (
fwmarkBase = loadFwmarkBase()
// ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to
// avoid routing loops.
// This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket.
// It doesn't collide with the other marks, as the others are used for data plane traffic only.
ControlPlaneMark = fwmarkBase | offsetControlPlane
// DataPlaneMarkLower is the lowest value for the data plane range
DataPlaneMarkLower = fwmarkBase | offsetDataPlaneLower
// DataPlaneMarkUpper is the highest value for the data plane range
DataPlaneMarkUpper = fwmarkBase | offsetDataPlaneUpper
// DataPlaneMarkIn is the mark for inbound data plane traffic.
DataPlaneMarkIn = fwmarkBase | offsetDataPlaneIn
// DataPlaneMarkOut is the mark for outbound data plane traffic.
DataPlaneMarkOut = fwmarkBase | offsetDataPlaneOut
// PreroutingFwmarkRedirected is applied to packets that were redirected (input -> forward, e.g. by Docker or Podman) for special handling.
PreroutingFwmarkRedirected = fwmarkBase | offsetRedirected
// PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded.
PreroutingFwmarkMasquerade = fwmarkBase | offsetMasquerade
// PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded.
PreroutingFwmarkMasqueradeReturn = fwmarkBase | offsetMasqueradeReturn
)
// IsDataPlaneMark determines if a fwmark is in the data plane range.
func IsDataPlaneMark(fwmark uint32) bool {
return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper
}
func loadFwmarkBase() uint32 {
val := os.Getenv(envFwmarkBase)
if val == "" {
return defaultFwmarkBase
}
base, err := parseFwmarkBase(val)
if err != nil {
log.Warnf("failed to parse %s=%q, using the default range: %v", envFwmarkBase, val, err)
return defaultFwmarkBase
}
log.Infof("using fwmark range %#x-%#x from %s", base, base|fwmarkOffsetMask, envFwmarkBase)
return base
}
// parseFwmarkBase reads a mark range base. The low byte of a mark identifies the
// individual mark within the range, so a base has to leave it free.
func parseFwmarkBase(val string) (uint32, error) {
val = strings.TrimSpace(val)
base, err := strconv.ParseUint(val, 0, 32)
if err != nil {
return 0, fmt.Errorf("not a 32 bit number: %w", err)
}
if base == 0 {
return 0, fmt.Errorf("base must not be zero")
}
if uint32(base)&fwmarkOffsetMask != 0 {
return 0, fmt.Errorf("base %#x must leave the low byte free", base)
}
return uint32(base), nil
}
+111
View File
@@ -0,0 +1,111 @@
package net
import (
"testing"
)
func TestParseFwmarkBase(t *testing.T) {
tests := []struct {
name string
val string
want uint32
wantErr bool
}{
{name: "hex", val: "0x5A000", want: 0x5A000},
{name: "hex upper case", val: "0X5A000", want: 0x5A000},
{name: "decimal", val: "65536", want: 65536},
{name: "octal", val: "0o400", want: 0o400},
{name: "surrounding space", val: " 0x5A000 ", want: 0x5A000},
{name: "highest usable base", val: "0xFFFFFF00", want: 0xFFFFFF00},
{name: "low byte in use", val: "0x1BD01", wantErr: true},
{name: "zero", val: "0", wantErr: true},
{name: "not a number", val: "wireguard", wantErr: true},
{name: "wider than 32 bit", val: "0x1FFFFFFFF", wantErr: true},
{name: "negative", val: "-0x100", wantErr: true},
{name: "empty", val: "", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseFwmarkBase(tc.val)
if tc.wantErr {
if err == nil {
t.Fatalf("parseFwmarkBase(%q) = %#x, want an error", tc.val, got)
}
return
}
if err != nil {
t.Fatalf("parseFwmarkBase(%q): %v", tc.val, err)
}
if got != tc.want {
t.Errorf("parseFwmarkBase(%q) = %#x, want %#x", tc.val, got, tc.want)
}
})
}
}
// The marks have to stay inside the range the base defines, otherwise a host
// that moved the range to dodge a collision would still emit the old values.
func TestMarksStayWithinTheRange(t *testing.T) {
lower, upper := fwmarkBase, fwmarkBase|fwmarkOffsetMask
marks := map[string]uint32{
"ControlPlaneMark": ControlPlaneMark,
"DataPlaneMarkLower": DataPlaneMarkLower,
"DataPlaneMarkUpper": DataPlaneMarkUpper,
"DataPlaneMarkIn": DataPlaneMarkIn,
"DataPlaneMarkOut": DataPlaneMarkOut,
"PreroutingFwmarkRedirected": PreroutingFwmarkRedirected,
"PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade,
"PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn,
}
for name, mark := range marks {
if mark < lower || mark > upper {
t.Errorf("%s = %#x, outside the range %#x-%#x", name, mark, lower, upper)
}
}
// the control plane mark must stay out of the data plane range, the netflow
// conntrack path tells them apart by it
if IsDataPlaneMark(ControlPlaneMark) {
t.Errorf("ControlPlaneMark %#x is inside the data plane range", ControlPlaneMark)
}
for name, mark := range map[string]uint32{
"DataPlaneMarkIn": DataPlaneMarkIn,
"DataPlaneMarkOut": DataPlaneMarkOut,
"PreroutingFwmarkRedirected": PreroutingFwmarkRedirected,
"PreroutingFwmarkMasquerade": PreroutingFwmarkMasquerade,
"PreroutingFwmarkMasqueradeReturn": PreroutingFwmarkMasqueradeReturn,
} {
if !IsDataPlaneMark(mark) {
t.Errorf("%s = %#x is outside the data plane range %#x-%#x", name, mark, DataPlaneMarkLower, DataPlaneMarkUpper)
}
}
}
func TestDefaultMarksAreUnchanged(t *testing.T) {
tests := map[string]struct {
got uint32
want uint32
}{
"ControlPlaneMark": {ControlPlaneMark, 0x1BD00},
"DataPlaneMarkLower": {DataPlaneMarkLower, 0x1BD10},
"DataPlaneMarkUpper": {DataPlaneMarkUpper, 0x1BDFF},
"DataPlaneMarkIn": {DataPlaneMarkIn, 0x1BD10},
"DataPlaneMarkOut": {DataPlaneMarkOut, 0x1BD11},
"PreroutingFwmarkRedirected": {PreroutingFwmarkRedirected, 0x1BD20},
"PreroutingFwmarkMasquerade": {PreroutingFwmarkMasquerade, 0x1BD21},
"PreroutingFwmarkMasqueradeReturn": {PreroutingFwmarkMasqueradeReturn, 0x1BD22},
}
if fwmarkBase != defaultFwmarkBase {
t.Skipf("%s is set, the defaults do not apply", envFwmarkBase)
}
for name, tc := range tests {
if tc.got != tc.want {
t.Errorf("%s = %#x, want %#x", name, tc.got, tc.want)
}
}
}
-35
View File
@@ -7,41 +7,6 @@ import (
"net/netip" "net/netip"
) )
const (
// ControlPlaneMark is the fwmark value used to mark packets that should not be routed through the NetBird interface to
// avoid routing loops.
// This includes all control plane traffic (mgmt, signal, flows), relay, ICE/stun/turn and everything that is emitted by the wireguard socket.
// It doesn't collide with the other marks, as the others are used for data plane traffic only.
ControlPlaneMark = 0x1BD00
// Data plane marks (0x1BD10 - 0x1BDFF)
// DataPlaneMarkLower is the lowest value for the data plane range
DataPlaneMarkLower = 0x1BD10
// DataPlaneMarkUpper is the highest value for the data plane range
DataPlaneMarkUpper = 0x1BDFF
// DataPlaneMarkIn is the mark for inbound data plane traffic.
DataPlaneMarkIn = 0x1BD10
// DataPlaneMarkOut is the mark for outbound data plane traffic.
DataPlaneMarkOut = 0x1BD11
// PreroutingFwmarkRedirected is applied to packets that are were redirected (input -> forward, e.g. by Docker or Podman) for special handling.
PreroutingFwmarkRedirected = 0x1BD20
// PreroutingFwmarkMasquerade is applied to packets that arrive from the NetBird interface and should be masqueraded.
PreroutingFwmarkMasquerade = 0x1BD21
// PreroutingFwmarkMasqueradeReturn is applied to packets that will leave through the NetBird interface and should be masqueraded.
PreroutingFwmarkMasqueradeReturn = 0x1BD22
)
// IsDataPlaneMark determines if a fwmark is in the data plane range (0x1BD10-0x1BDFF)
func IsDataPlaneMark(fwmark uint32) bool {
return fwmark >= DataPlaneMarkLower && fwmark <= DataPlaneMarkUpper
}
func GetLastIPFromNetwork(network netip.Prefix, fromEnd int) (netip.Addr, error) { func GetLastIPFromNetwork(network netip.Prefix, fromEnd int) (netip.Addr, error) {
var endIP net.IP var endIP net.IP
addr := network.Addr().AsSlice() addr := network.Addr().AsSlice()
+1 -10
View File
@@ -21,15 +21,6 @@ func SetSocketMark(conn syscall.Conn) error {
return setRawSocketMark(sysconn) return setRawSocketMark(sysconn)
} }
// SetSocketOpt sets the SO_MARK option on the given file descriptor
func SetSocketOpt(fd int) error {
if !AdvancedRouting() {
return nil
}
return setSocketOptInt(fd)
}
func setRawSocketMark(conn syscall.RawConn) error { func setRawSocketMark(conn syscall.RawConn) error {
var setErr error var setErr error
@@ -51,5 +42,5 @@ func setRawSocketMark(conn syscall.RawConn) error {
} }
func setSocketOptInt(fd int) error { func setSocketOptInt(fd int) error {
return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, ControlPlaneMark) return syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, int(ControlPlaneMark))
} }
+173
View File
@@ -0,0 +1,173 @@
// Package netevents owns the OS network event handling shared by the mobile
// bindings: availability changes park or wake the reconnection loops and drive
// the NoNetwork listener state, and both losing the last network and switching
// networks sweep the stale connections so their owners redial immediately.
package netevents
import (
"context"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/netevents/netstate"
"github.com/netbirdio/netbird/client/netevents/sweep"
)
// Recorder receives the availability changes for listener state reporting.
type Recorder interface {
SetNetworkAvailable(available bool)
}
// Manager ties the network availability state, the connection sweeper and the
// status recorder together; it outlives engine restarts. A nil *Manager is
// the valid no-events value for consumers: the read methods report
// always-online and never sweep. Only the event sources hold a real Manager,
// so the write methods do not tolerate a nil receiver.
type Manager struct {
// mu serializes availability transitions: the IsOnline check and the
// state update must be atomic, or a racing offline flip can skip the sweep
// and leave netState and the recorder disagreeing.
mu sync.Mutex
netState *netstate.State
sweeper *sweep.Sweeper
recorder Recorder
}
// NewManager creates a Manager reporting into recorder, starting online.
func NewManager(recorder Recorder) *Manager {
return &Manager{
netState: netstate.New(),
sweeper: sweep.New(),
recorder: recorder,
}
}
// SetNetworkAvailable records OS-reported network availability. While
// unavailable, the reconnection 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.
//
// Panics on a nil receiver: only the mobile bindings that own a Manager
// report availability.
func (m *Manager) SetNetworkAvailable(available bool) {
m.mu.Lock()
defer m.mu.Unlock()
if !available && m.netState.IsOnline() {
m.sweeper.MarkNetworkChange()
}
m.netState.Set(available)
m.recorder.SetNetworkAvailable(available)
}
// NotifyNetworkChange marks the management, signal and relay connections
// stale after the OS switched networks and schedules a sweep that cuts
// whatever has not redialed on the new network by then. The engine and the
// TUN device stay untouched.
//
// Panics on a nil receiver: only the mobile bindings that own a Manager
// report network changes.
func (m *Manager) NotifyNetworkChange() {
m.sweeper.MarkNetworkChange()
log.Infof("network change: connections marked stale")
}
// IsOnline reports whether the OS reports at least one usable network.
func (m *Manager) IsOnline() bool {
if m == nil {
return true
}
return m.netState.IsOnline()
}
// Changed returns a channel closed on the next availability transition.
func (m *Manager) Changed() <-chan struct{} {
if m == nil {
return nil
}
return m.netState.Changed()
}
// Wait blocks while the network is offline; see netstate.State.Wait.
func (m *Manager) Wait(ctx context.Context) (bool, error) {
if m == nil {
return false, nil
}
return m.netState.Wait(ctx)
}
// WaitSettled waits until an online verdict holds for a full settleWindow, or
// while offline until the budget runs out. Returns false when ctx is
// cancelled. The settle window exists because a disconnect often precedes the
// OS offline flag by a few milliseconds, so a fresh online verdict cannot be
// trusted immediately. A nil Manager has no events to watch: it degrades to a
// fixed budget-long sleep.
func (m *Manager) WaitSettled(ctx context.Context, budget, settleWindow time.Duration) bool {
if m == nil {
select {
case <-time.After(budget):
return true
case <-ctx.Done():
return false
}
}
budgetTimer := time.NewTimer(budget)
defer budgetTimer.Stop()
settle := time.NewTimer(settleWindow)
defer settle.Stop()
for {
// Channel first, flag second: a flip in between still fires the channel.
changedCh := m.netState.Changed()
if m.netState.IsOnline() {
select {
case <-settle.C:
return true
case <-changedCh:
case <-ctx.Done():
return false
}
} else {
select {
case <-budgetTimer.C:
return true
case <-changedCh:
case <-ctx.Done():
return false
}
}
if !settle.Stop() {
select {
case <-settle.C:
default:
}
}
settle.Reset(settleWindow)
}
}
// StartDial registers an in-flight dial with the sweeper; see sweep.Sweeper.StartDial.
func (m *Manager) StartDial(ctx context.Context) *sweep.Dial {
if m == nil {
return (*sweep.Sweeper)(nil).StartDial(ctx)
}
return m.sweeper.StartDial(ctx)
}
// QuickRetryBackoff wraps bo for a quick retry after a network change; see
// sweep.Sweeper.QuickRetryBackoff.
func (m *Manager) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff) backoff.BackOff {
if m == nil {
return bo
}
return m.sweeper.QuickRetryBackoff(ctx, bo, m.netState)
}
+34
View File
@@ -0,0 +1,34 @@
package netevents
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
type recorderStub struct{}
func (recorderStub) SetNetworkAvailable(bool) {}
func TestWaitSettledAfterOutage(t *testing.T) {
const budget = 1500 * time.Millisecond
const settleWindow = 200 * time.Millisecond
const outage = 2 * settleWindow
m := NewManager(recorderStub{})
m.SetNetworkAvailable(false)
start := time.Now()
go func() {
time.Sleep(outage)
m.SetNetworkAvailable(true)
}()
ok := m.WaitSettled(context.Background(), budget, settleWindow)
elapsed := time.Since(start)
assert.True(t, ok, "recovered network must let the caller proceed")
assert.GreaterOrEqual(t, elapsed, outage+settleWindow, "an online verdict must hold a full settle window before it is trusted")
}
@@ -1,11 +1,11 @@
package netsweep package sweep
import ( import (
"time" "time"
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
"github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/client/netevents/netstate"
) )
const quickRetryDelay = 200 * time.Millisecond const quickRetryDelay = 200 * time.Millisecond
@@ -1,4 +1,4 @@
package netsweep package sweep
import ( import (
"context" "context"
@@ -1,10 +1,10 @@
// Package netsweep cuts network-bound activity when the OS switches networks: // Package sweep cuts network-bound activity when the OS switches networks:
// a sweep closes the registered connections and aborts the in-flight dials, so // a sweep closes the registered connections and aborts the in-flight dials, so
// their owners redial immediately instead of waiting for the old sockets to // their owners redial immediately instead of waiting for the old sockets to
// time out. // time out.
// //
// A nil *Sweeper disables everything: all methods are nil-safe no-ops. // A nil *Sweeper disables everything: all methods are nil-safe no-ops.
package netsweep package sweep
import ( import (
"context" "context"
@@ -16,7 +16,7 @@ import (
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/client/netevents/netstate"
) )
// DefaultSweepDelay absorbs network flapping while the OS settles on a // DefaultSweepDelay absorbs network flapping while the OS settles on a
@@ -34,7 +34,7 @@ type Config struct {
// ErrSwept reports that a dial finished after a network change swept its // ErrSwept reports that a dial finished after a network change swept its
// registration. The connection is already closed; the caller must treat it // registration. The connection is already closed; the caller must treat it
// as a failed dial and redial on the new network. // as a failed dial and redial on the new network.
var ErrSwept = errors.New("netsweep: connection swept by network change") var ErrSwept = errors.New("sweep: connection swept by network change")
// sweepID identifies one registration in a sweeper. Connections and dials // sweepID identifies one registration in a sweeper. Connections and dials
// draw from the same counter, so an id is unique across both registries. // draw from the same counter, so an id is unique across both registries.
@@ -1,4 +1,4 @@
package netsweep package sweep
import ( import (
"context" "context"
+61 -21
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.6 // protoc-gen-go v1.36.6
// protoc v6.33.1 // protoc v7.34.1
// source: daemon.proto // source: daemon.proto
package proto package proto
@@ -346,8 +346,10 @@ type LoginRequest struct {
DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
ServerVNCAllowed *bool `protobuf:"varint,41,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` EnableLocalMetrics *bool `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
DisableVNCApproval *bool `protobuf:"varint,42,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"` LocalMetricsAddress *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
ServerVNCAllowed *bool `protobuf:"varint,43,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"`
DisableVNCApproval *bool `protobuf:"varint,44,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -663,6 +665,20 @@ func (x *LoginRequest) GetDisableIpv6() bool {
return false return false
} }
func (x *LoginRequest) GetEnableLocalMetrics() bool {
if x != nil && x.EnableLocalMetrics != nil {
return *x.EnableLocalMetrics
}
return false
}
func (x *LoginRequest) GetLocalMetricsAddress() string {
if x != nil && x.LocalMetricsAddress != nil {
return *x.LocalMetricsAddress
}
return ""
}
func (x *LoginRequest) GetServerVNCAllowed() bool { func (x *LoginRequest) GetServerVNCAllowed() bool {
if x != nil && x.ServerVNCAllowed != nil { if x != nil && x.ServerVNCAllowed != nil {
return *x.ServerVNCAllowed return *x.ServerVNCAllowed
@@ -1234,14 +1250,14 @@ type GetConfigResponse struct {
DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"` SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"` DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"`
ServerVNCAllowed bool `protobuf:"varint,28,opt,name=serverVNCAllowed,proto3" json:"serverVNCAllowed,omitempty"` ServerVNCAllowed bool `protobuf:"varint,29,opt,name=serverVNCAllowed,proto3" json:"serverVNCAllowed,omitempty"`
DisableVNCApproval bool `protobuf:"varint,29,opt,name=disableVNCApproval,proto3" json:"disableVNCApproval,omitempty"` DisableVNCApproval bool `protobuf:"varint,30,opt,name=disableVNCApproval,proto3" json:"disableVNCApproval,omitempty"`
// mDMManagedFields lists the names of configuration keys whose value is // mDMManagedFields lists the names of configuration keys whose value is
// currently enforced by an MDM policy. Names match mdm.Key* constants // currently enforced by an MDM policy. Names match mdm.Key* constants
// (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
// render the corresponding inputs as read-only and display a "managed // render the corresponding inputs as read-only and display a "managed
// by MDM" indicator. // by MDM" indicator.
MDMManagedFields []string `protobuf:"bytes,30,rep,name=mDMManagedFields,proto3" json:"mDMManagedFields,omitempty"` MDMManagedFields []string `protobuf:"bytes,28,rep,name=mDMManagedFields,proto3" json:"mDMManagedFields,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -4410,8 +4426,10 @@ type SetConfigRequest struct {
DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
ServerVNCAllowed *bool `protobuf:"varint,36,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"` EnableLocalMetrics *bool `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
DisableVNCApproval *bool `protobuf:"varint,37,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"` LocalMetricsAddress *string `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,omitempty"`
ServerVNCAllowed *bool `protobuf:"varint,38,opt,name=serverVNCAllowed,proto3,oneof" json:"serverVNCAllowed,omitempty"`
DisableVNCApproval *bool `protobuf:"varint,39,opt,name=disableVNCApproval,proto3,oneof" json:"disableVNCApproval,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -4691,6 +4709,20 @@ func (x *SetConfigRequest) GetDisableIpv6() bool {
return false return false
} }
func (x *SetConfigRequest) GetEnableLocalMetrics() bool {
if x != nil && x.EnableLocalMetrics != nil {
return *x.EnableLocalMetrics
}
return false
}
func (x *SetConfigRequest) GetLocalMetricsAddress() string {
if x != nil && x.LocalMetricsAddress != nil {
return *x.LocalMetricsAddress
}
return ""
}
func (x *SetConfigRequest) GetServerVNCAllowed() bool { func (x *SetConfigRequest) GetServerVNCAllowed() bool {
if x != nil && x.ServerVNCAllowed != nil { if x != nil && x.ServerVNCAllowed != nil {
return *x.ServerVNCAllowed return *x.ServerVNCAllowed
@@ -7328,7 +7360,7 @@ var File_daemon_proto protoreflect.FileDescriptor
const file_daemon_proto_rawDesc = "" + const file_daemon_proto_rawDesc = "" +
"\n" + "\n" +
"\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" +
"\fEmptyRequest\"\x81\x14\n" + "\fEmptyRequest\"\xa4\x15\n" +
"\fLoginRequest\x12\x1a\n" + "\fLoginRequest\x12\x1a\n" +
"\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" +
"\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" +
@@ -7373,9 +7405,11 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
"\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
"\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
"\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x12/\n" + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" +
"\x10serverVNCAllowed\x18) \x01(\bH\x1cR\x10serverVNCAllowed\x88\x01\x01\x123\n" + "\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" +
"\x12disableVNCApproval\x18* \x01(\bH\x1dR\x12disableVNCApproval\x88\x01\x01B\x13\n" + "\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01\x12/\n" +
"\x10serverVNCAllowed\x18+ \x01(\bH\x1eR\x10serverVNCAllowed\x88\x01\x01\x123\n" +
"\x12disableVNCApproval\x18, \x01(\bH\x1fR\x12disableVNCApproval\x88\x01\x01B\x13\n" +
"\x11_rosenpassEnabledB\x10\n" + "\x11_rosenpassEnabledB\x10\n" +
"\x0e_interfaceNameB\x10\n" + "\x0e_interfaceNameB\x10\n" +
"\x0e_wireguardPortB\x17\n" + "\x0e_wireguardPortB\x17\n" +
@@ -7403,7 +7437,9 @@ const file_daemon_proto_rawDesc = "" +
"\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x1e_enableSSHRemotePortForwardingB\x11\n" +
"\x0f_disableSSHAuthB\x11\n" + "\x0f_disableSSHAuthB\x11\n" +
"\x0f_sshJWTCacheTTLB\x0f\n" + "\x0f_sshJWTCacheTTLB\x0f\n" +
"\r_disable_ipv6B\x13\n" + "\r_disable_ipv6B\x17\n" +
"\x15_enable_local_metricsB\x18\n" +
"\x16_local_metrics_addressB\x13\n" +
"\x11_serverVNCAllowedB\x15\n" + "\x11_serverVNCAllowedB\x15\n" +
"\x13_disableVNCApproval\"\xb5\x01\n" + "\x13_disableVNCApproval\"\xb5\x01\n" +
"\rLoginResponse\x12$\n" + "\rLoginResponse\x12$\n" +
@@ -7474,9 +7510,9 @@ const file_daemon_proto_rawDesc = "" +
"\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" + "\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" +
"\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" + "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" +
"\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" +
"\x10serverVNCAllowed\x18\x1c \x01(\bR\x10serverVNCAllowed\x12.\n" + "\x10serverVNCAllowed\x18\x1d \x01(\bR\x10serverVNCAllowed\x12.\n" +
"\x12disableVNCApproval\x18\x1d \x01(\bR\x12disableVNCApproval\x12*\n" + "\x12disableVNCApproval\x18\x1e \x01(\bR\x12disableVNCApproval\x12*\n" +
"\x10mDMManagedFields\x18\x1e \x03(\tR\x10mDMManagedFields\"\x92\x06\n" + "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" +
"\tPeerState\x12\x0e\n" + "\tPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" + "\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" +
@@ -7714,7 +7750,7 @@ const file_daemon_proto_rawDesc = "" +
"\f_profileNameB\v\n" + "\f_profileNameB\v\n" +
"\t_username\"'\n" + "\t_username\"'\n" +
"\x15SwitchProfileResponse\x12\x0e\n" + "\x15SwitchProfileResponse\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\"\xaa\x12\n" + "\x02id\x18\x01 \x01(\tR\x02id\"\xcd\x13\n" +
"\x10SetConfigRequest\x12\x1a\n" + "\x10SetConfigRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" +
"\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" +
@@ -7754,9 +7790,11 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" +
"\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" +
"\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
"\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x12/\n" + "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" +
"\x10serverVNCAllowed\x18$ \x01(\bH\x19R\x10serverVNCAllowed\x88\x01\x01\x123\n" + "\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" +
"\x12disableVNCApproval\x18% \x01(\bH\x1aR\x12disableVNCApproval\x88\x01\x01B\x13\n" + "\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01\x12/\n" +
"\x10serverVNCAllowed\x18& \x01(\bH\x1bR\x10serverVNCAllowed\x88\x01\x01\x123\n" +
"\x12disableVNCApproval\x18' \x01(\bH\x1cR\x12disableVNCApproval\x88\x01\x01B\x13\n" +
"\x11_rosenpassEnabledB\x10\n" + "\x11_rosenpassEnabledB\x10\n" +
"\x0e_interfaceNameB\x10\n" + "\x0e_interfaceNameB\x10\n" +
"\x0e_wireguardPortB\x17\n" + "\x0e_wireguardPortB\x17\n" +
@@ -7781,7 +7819,9 @@ const file_daemon_proto_rawDesc = "" +
"\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x1e_enableSSHRemotePortForwardingB\x11\n" +
"\x0f_disableSSHAuthB\x11\n" + "\x0f_disableSSHAuthB\x11\n" +
"\x0f_sshJWTCacheTTLB\x0f\n" + "\x0f_sshJWTCacheTTLB\x0f\n" +
"\r_disable_ipv6B\x13\n" + "\r_disable_ipv6B\x17\n" +
"\x15_enable_local_metricsB\x18\n" +
"\x16_local_metrics_addressB\x13\n" +
"\x11_serverVNCAllowedB\x15\n" + "\x11_serverVNCAllowedB\x15\n" +
"\x13_disableVNCApproval\"\x13\n" + "\x13_disableVNCApproval\"\x13\n" +
"\x11SetConfigResponse\"Q\n" + "\x11SetConfigResponse\"Q\n" +
+13 -7
View File
@@ -251,9 +251,12 @@ message LoginRequest {
optional int32 sshJWTCacheTTL = 39; optional int32 sshJWTCacheTTL = 39;
optional bool disable_ipv6 = 40; optional bool disable_ipv6 = 40;
optional bool serverVNCAllowed = 41; optional bool enable_local_metrics = 41;
optional string local_metrics_address = 42;
optional bool disableVNCApproval = 42; optional bool serverVNCAllowed = 43;
optional bool disableVNCApproval = 44;
} }
message LoginResponse { message LoginResponse {
@@ -374,16 +377,16 @@ message GetConfigResponse {
bool disable_ipv6 = 27; bool disable_ipv6 = 27;
bool serverVNCAllowed = 28; bool serverVNCAllowed = 29;
bool disableVNCApproval = 29; bool disableVNCApproval = 30;
// mDMManagedFields lists the names of configuration keys whose value is // mDMManagedFields lists the names of configuration keys whose value is
// currently enforced by an MDM policy. Names match mdm.Key* constants // currently enforced by an MDM policy. Names match mdm.Key* constants
// (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
// render the corresponding inputs as read-only and display a "managed // render the corresponding inputs as read-only and display a "managed
// by MDM" indicator. // by MDM" indicator.
repeated string mDMManagedFields = 30; repeated string mDMManagedFields = 28;
} }
// PeerState contains the latest state of a peer // PeerState contains the latest state of a peer
@@ -804,9 +807,12 @@ message SetConfigRequest {
optional int32 sshJWTCacheTTL = 34; optional int32 sshJWTCacheTTL = 34;
optional bool disable_ipv6 = 35; optional bool disable_ipv6 = 35;
optional bool serverVNCAllowed = 36; optional bool enable_local_metrics = 36;
optional string local_metrics_address = 37;
optional bool disableVNCApproval = 37; optional bool serverVNCAllowed = 38;
optional bool disableVNCApproval = 39;
} }
message SetConfigResponse{} message SetConfigResponse{}
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v6.33.1 // - protoc v7.34.1
// source: daemon.proto // source: daemon.proto
package proto package proto

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