mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-20 20:39:08 +02:00
Merge branch 'main' into embedded-vnc
This commit is contained in:
@@ -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
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -37,3 +37,16 @@ jobs:
|
|||||||
repo: netbirdio/ios-client
|
repo: netbirdio/ios-client
|
||||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||||
|
|
||||||
|
trigger_dashboard_bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||||
|
steps:
|
||||||
|
- name: Trigger dashboard wasm client bump
|
||||||
|
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||||
|
with:
|
||||||
|
workflow: bump-netbird.yml
|
||||||
|
ref: main
|
||||||
|
repo: netbirdio/dashboard
|
||||||
|
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||||
|
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ nfpms:
|
|||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
|
# Names the polkit action for the elevation prompt the app raises when an
|
||||||
|
# unprivileged user changes a privileged setting; without it the dialog
|
||||||
|
# shows a raw command line.
|
||||||
|
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
|
||||||
|
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird (>= 0.75.0)
|
- netbird (>= 0.75.0)
|
||||||
- libgtk-4-1 (>= 4.14)
|
- libgtk-4-1 (>= 4.14)
|
||||||
@@ -116,6 +121,11 @@ nfpms:
|
|||||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||||
- src: client/ui/build/appicon.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
|
# Names the polkit action for the elevation prompt the app raises when an
|
||||||
|
# unprivileged user changes a privileged setting; without it the dialog
|
||||||
|
# shows a raw command line.
|
||||||
|
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
|
||||||
|
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird >= 0.75.0
|
- netbird >= 0.75.0
|
||||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||||
|
|||||||
+17
-18
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func startManagement(t *testing.T, signalAddr string) string {
|
|||||||
|
|
||||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||||
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
|
requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
|
||||||
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg)
|
networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil)
|
||||||
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -763,7 +763,7 @@ func (r *router) addNatRule(pair firewall.RouterPair) error {
|
|||||||
exprs = append(exprs, sourceExp...)
|
exprs = append(exprs, sourceExp...)
|
||||||
exprs = append(exprs, destExp...)
|
exprs = append(exprs, destExp...)
|
||||||
|
|
||||||
var markValue uint32 = nbnet.PreroutingFwmarkMasquerade
|
markValue := nbnet.PreroutingFwmarkMasquerade
|
||||||
if pair.Inverse {
|
if pair.Inverse {
|
||||||
markValue = nbnet.PreroutingFwmarkMasqueradeReturn
|
markValue = nbnet.PreroutingFwmarkMasqueradeReturn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -2,6 +2,7 @@ package internal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||||
"github.com/netbirdio/netbird/route"
|
"github.com/netbirdio/netbird/route"
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// lazyForce is the resolved local decision for lazy connections, layered above the
|
// lazyForce is the resolved local decision for lazy connections, layered above the
|
||||||
@@ -37,11 +39,13 @@ const (
|
|||||||
// The only exception is ActivatePeer, which is safe for concurrent use so the
|
// The only exception is ActivatePeer, which is safe for concurrent use so the
|
||||||
// DNS warm-up path can call it without contending on the engine mutex.
|
// DNS warm-up path can call it without contending on the engine mutex.
|
||||||
type ConnMgr struct {
|
type ConnMgr struct {
|
||||||
peerStore *peerstore.Store
|
peerStore *peerstore.Store
|
||||||
statusRecorder *peer.Status
|
statusRecorder *peer.Status
|
||||||
iface lazyconn.WGIface
|
iface lazyconn.WGIface
|
||||||
force lazyForce
|
force lazyForce
|
||||||
rosenpassEnabled bool
|
// remoteLazyEnabled caches the account-wide lazy feature flag from management.
|
||||||
|
// It is the default for peers that do not carry a per-peer lazy hint.
|
||||||
|
remoteLazyEnabled bool
|
||||||
|
|
||||||
lazyConnMgr *manager.Manager
|
lazyConnMgr *manager.Manager
|
||||||
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
|
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
|
||||||
@@ -53,6 +57,10 @@ type ConnMgr struct {
|
|||||||
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
|
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
|
||||||
reconcileRoutedIPs func(peerKey string) error
|
reconcileRoutedIPs func(peerKey string) error
|
||||||
|
|
||||||
|
// appliedExcludeList is the exclude set last handed to the lazy manager, kept so an
|
||||||
|
// unchanged set on the next sync skips the O(n) reconciliation.
|
||||||
|
appliedExcludeList map[string]bool
|
||||||
|
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
lazyCtx context.Context
|
lazyCtx context.Context
|
||||||
lazyCtxCancel context.CancelFunc
|
lazyCtxCancel context.CancelFunc
|
||||||
@@ -66,78 +74,59 @@ func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
|
|||||||
|
|
||||||
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
|
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
|
||||||
e := &ConnMgr{
|
e := &ConnMgr{
|
||||||
peerStore: peerStore,
|
peerStore: peerStore,
|
||||||
statusRecorder: statusRecorder,
|
statusRecorder: statusRecorder,
|
||||||
iface: iface,
|
iface: iface,
|
||||||
force: resolveLazyForce(engineConfig.LazyConnection),
|
force: resolveLazyForce(engineConfig.LazyConnection),
|
||||||
rosenpassEnabled: engineConfig.RosenpassEnabled,
|
|
||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initializes the connection manager. It starts the lazy connection manager when a
|
// Start initializes the connection manager. The lazy connection manager always runs so that
|
||||||
// local override forces it on; with no local override it waits for the management feature flag.
|
// per-peer lazy defaults (e.g. proxy peers) work even when the account flag is off; the
|
||||||
|
// account flag and the local override decide the default lazy state per peer (see
|
||||||
|
// PeerLazyDefault). Rosenpass peers stay lazy-capable too: their connections just never idle
|
||||||
|
// on their own, since rosenpass rekey traffic keeps them active.
|
||||||
func (e *ConnMgr) Start(ctx context.Context) {
|
func (e *ConnMgr) Start(ctx context.Context) {
|
||||||
if e.lazyConnMgr != nil {
|
if e.lazyConnMgr != nil {
|
||||||
log.Errorf("lazy connection manager is already started")
|
log.Errorf("lazy connection manager is already started")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch e.force {
|
|
||||||
case lazyForceOff:
|
|
||||||
log.Infof("lazy connection manager is disabled by local override (%s or MDM policy)", lazyconn.EnvLazyConn)
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return
|
|
||||||
case lazyForceNone:
|
|
||||||
log.Infof("lazy connection manager is managed by the management feature flag")
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.rosenpassEnabled {
|
|
||||||
log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started")
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.initLazyManager(ctx)
|
e.initLazyManager(ctx)
|
||||||
e.statusRecorder.UpdateLazyConnection(true)
|
e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatedRemoteFeatureFlag is called when the remote feature flag is updated.
|
// UpdatedRemoteFeatureFlag caches the account-wide lazy feature flag. The manager itself is
|
||||||
// If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again.
|
// not started or stopped here; the per-sync exclude-list reconciliation moves normal peers
|
||||||
// If disabled, then it closes the lazy connection manager and open the connections to all peers.
|
// between the lazy and always-active sets when the flag flips.
|
||||||
func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error {
|
func (e *ConnMgr) UpdatedRemoteFeatureFlag(_ context.Context, enabled bool) error {
|
||||||
// a local override (NB_LAZY_CONN or local config) takes precedence over management
|
e.remoteLazyEnabled = enabled
|
||||||
if e.force != lazyForceNone {
|
if e.isStartedWithLazyMgr() {
|
||||||
return nil
|
e.statusRecorder.UpdateLazyConnection(e.PeerLazyDefault(mgmProto.LazyState_LazyStateDefault))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerLazyDefault reports whether a peer should be lazy. The local override
|
||||||
|
// (NB_LAZY_CONN/MDM) wins over everything; without a local override the
|
||||||
|
// management per-peer state applies (LazyStateLazy/Eager force the decision),
|
||||||
|
// and LazyStateDefault follows the account-wide flag.
|
||||||
|
func (e *ConnMgr) PeerLazyDefault(state mgmProto.LazyState) bool {
|
||||||
|
switch e.force {
|
||||||
|
case lazyForceOn:
|
||||||
|
return true
|
||||||
|
case lazyForceOff:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
switch state {
|
||||||
// if the lazy connection manager is already started, do not start it again
|
case mgmProto.LazyState_LazyStateLazy:
|
||||||
if e.lazyConnMgr != nil {
|
return true
|
||||||
return nil
|
case mgmProto.LazyState_LazyStateEager:
|
||||||
}
|
return false
|
||||||
|
default:
|
||||||
if e.rosenpassEnabled {
|
return e.remoteLazyEnabled
|
||||||
log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started")
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("lazy connection manager is enabled by the management feature flag")
|
|
||||||
e.initLazyManager(ctx)
|
|
||||||
e.statusRecorder.UpdateLazyConnection(true)
|
|
||||||
return e.addPeersToLazyConnManager()
|
|
||||||
} else {
|
|
||||||
if e.lazyConnMgr == nil {
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Infof("lazy connection manager is disabled by management feature flag")
|
|
||||||
e.closeManager(ctx)
|
|
||||||
e.statusRecorder.UpdateLazyConnection(false)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +146,13 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The exclude set is recomputed every sync but rarely changes; skip the O(n)
|
||||||
|
// store lookups and reconciliation when it matches what was already applied.
|
||||||
|
if maps.Equal(peerIDs, e.appliedExcludeList) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.appliedExcludeList = maps.Clone(peerIDs)
|
||||||
|
|
||||||
excludedPeers := make([]lazyconn.PeerConfig, 0, len(peerIDs))
|
excludedPeers := make([]lazyconn.PeerConfig, 0, len(peerIDs))
|
||||||
|
|
||||||
for peerID := range peerIDs {
|
for peerID := range peerIDs {
|
||||||
@@ -192,12 +188,16 @@ func (e *ConnMgr) SetExcludeList(ctx context.Context, peerIDs map[string]bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn) (exists bool) {
|
// AddPeerConn registers a peer connection. permanent requests an always-active connection
|
||||||
|
// (the peer belongs to the exclude set: a forwarder, or a peer that is not lazy by policy).
|
||||||
|
// Non-permanent peers are handed to the lazy manager. The subsequent SetExcludeList call
|
||||||
|
// reconciles membership for existing peers across flag flips.
|
||||||
|
func (e *ConnMgr) AddPeerConn(ctx context.Context, peerKey string, conn *peer.Conn, permanent bool) (exists bool) {
|
||||||
if success := e.peerStore.AddPeerConn(peerKey, conn); !success {
|
if success := e.peerStore.AddPeerConn(peerKey, conn); !success {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !e.isStartedWithLazyMgr() {
|
if !e.isStartedWithLazyMgr() || permanent {
|
||||||
if err := conn.Open(ctx); err != nil {
|
if err := conn.Open(ctx); err != nil {
|
||||||
conn.Log.Errorf("failed to open connection: %v", err)
|
conn.Log.Errorf("failed to open connection: %v", err)
|
||||||
}
|
}
|
||||||
@@ -296,6 +296,8 @@ func (e *ConnMgr) Close() {
|
|||||||
e.lazyConnMgrMu.Lock()
|
e.lazyConnMgrMu.Lock()
|
||||||
e.lazyConnMgr = nil
|
e.lazyConnMgr = nil
|
||||||
e.lazyConnMgrMu.Unlock()
|
e.lazyConnMgrMu.Unlock()
|
||||||
|
|
||||||
|
e.appliedExcludeList = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
||||||
@@ -309,6 +311,8 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
|||||||
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
|
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
|
||||||
e.lazyConnMgrMu.Unlock()
|
e.lazyConnMgrMu.Unlock()
|
||||||
|
|
||||||
|
e.appliedExcludeList = nil
|
||||||
|
|
||||||
e.wg.Add(1)
|
e.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer e.wg.Done()
|
defer e.wg.Done()
|
||||||
@@ -316,46 +320,6 @@ func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ConnMgr) addPeersToLazyConnManager() error {
|
|
||||||
peers := e.peerStore.PeersPubKey()
|
|
||||||
lazyPeerCfgs := make([]lazyconn.PeerConfig, 0, len(peers))
|
|
||||||
for _, peerID := range peers {
|
|
||||||
var peerConn *peer.Conn
|
|
||||||
var exists bool
|
|
||||||
if peerConn, exists = e.peerStore.PeerConn(peerID); !exists {
|
|
||||||
log.Warnf("failed to find peer conn for peerID: %s", peerID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lazyPeerCfg := lazyconn.PeerConfig{
|
|
||||||
PublicKey: peerID,
|
|
||||||
AllowedIPs: peerConn.WgConfig().AllowedIps,
|
|
||||||
PeerConnID: peerConn.ConnID(),
|
|
||||||
Log: peerConn.Log,
|
|
||||||
}
|
|
||||||
lazyPeerCfgs = append(lazyPeerCfgs, lazyPeerCfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.lazyConnMgr.AddActivePeers(lazyPeerCfgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConnMgr) closeManager(ctx context.Context) {
|
|
||||||
if e.lazyConnMgr == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.lazyCtxCancel()
|
|
||||||
e.wg.Wait()
|
|
||||||
|
|
||||||
e.lazyConnMgrMu.Lock()
|
|
||||||
e.lazyConnMgr = nil
|
|
||||||
e.lazyConnMgrMu.Unlock()
|
|
||||||
|
|
||||||
for _, peerID := range e.peerStore.PeersPubKey() {
|
|
||||||
e.peerStore.PeerConnOpen(ctx, peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ConnMgr) isStartedWithLazyMgr() bool {
|
func (e *ConnMgr) isStartedWithLazyMgr() bool {
|
||||||
return e.lazyConnMgr != nil && e.lazyCtxCancel != nil
|
return e.lazyConnMgr != nil && e.lazyCtxCancel != nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/internal/peer"
|
"github.com/netbirdio/netbird/client/internal/peer"
|
||||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||||
"github.com/netbirdio/netbird/monotime"
|
"github.com/netbirdio/netbird/monotime"
|
||||||
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveLazyForce(t *testing.T) {
|
func TestResolveLazyForce(t *testing.T) {
|
||||||
@@ -138,4 +139,91 @@ func TestInactivityThresholdEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPeerLazyDefault(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
force lazyForce
|
||||||
|
remoteEnabled bool
|
||||||
|
state mgmProto.LazyState
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "force on wins over eager state", force: lazyForceOn, state: mgmProto.LazyState_LazyStateEager, want: true},
|
||||||
|
{name: "force off wins over lazy state", force: lazyForceOff, remoteEnabled: true, state: mgmProto.LazyState_LazyStateLazy, want: false},
|
||||||
|
{name: "none, default, account off -> active", force: lazyForceNone, state: mgmProto.LazyState_LazyStateDefault, want: false},
|
||||||
|
{name: "none, default, account on -> lazy", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateDefault, want: true},
|
||||||
|
{name: "none, lazy state, account off -> lazy", force: lazyForceNone, state: mgmProto.LazyState_LazyStateLazy, want: true},
|
||||||
|
{name: "none, eager state, account on -> active", force: lazyForceNone, remoteEnabled: true, state: mgmProto.LazyState_LazyStateEager, want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled}
|
||||||
|
if got := e.PeerLazyDefault(tt.state); got != tt.want {
|
||||||
|
t.Fatalf("PeerLazyDefault(%v) = %v, want %v", tt.state, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func durPtr(d time.Duration) *time.Duration { return &d }
|
func durPtr(d time.Duration) *time.Duration { return &d }
|
||||||
|
|
||||||
|
// TestToExcludedLazyPeers covers the per-peer lazy classification (proxy vs
|
||||||
|
// normal, across the force/account-flag matrix). Forwarder-target exclusion is
|
||||||
|
// covered by TestToExcludedLazyPeers_ForwardTarget.
|
||||||
|
func TestToExcludedLazyPeers(t *testing.T) {
|
||||||
|
const (
|
||||||
|
normalKey = "normal"
|
||||||
|
lazyKey = "lazy-state"
|
||||||
|
eagerKey = "eager-state"
|
||||||
|
)
|
||||||
|
|
||||||
|
peers := []*mgmProto.RemotePeerConfig{
|
||||||
|
{WgPubKey: normalKey, AllowedIps: []string{"100.64.0.1/32"}},
|
||||||
|
{WgPubKey: lazyKey, AllowedIps: []string{"100.64.0.2/32"}, LazyState: mgmProto.LazyState_LazyStateLazy},
|
||||||
|
{WgPubKey: eagerKey, AllowedIps: []string{"100.64.0.3/32"}, LazyState: mgmProto.LazyState_LazyStateEager},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
force lazyForce
|
||||||
|
remoteEnabled bool
|
||||||
|
want map[string]bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "account off: lazy-state peer lazy, normal + eager active",
|
||||||
|
force: lazyForceNone, remoteEnabled: false,
|
||||||
|
want: map[string]bool{normalKey: true, eagerKey: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "account on: only eager-state peer active",
|
||||||
|
force: lazyForceNone, remoteEnabled: true,
|
||||||
|
want: map[string]bool{eagerKey: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "force off: everything active",
|
||||||
|
force: lazyForceOff, remoteEnabled: true,
|
||||||
|
want: map[string]bool{normalKey: true, lazyKey: true, eagerKey: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "force on: nothing active",
|
||||||
|
force: lazyForceOn, remoteEnabled: false,
|
||||||
|
want: map[string]bool{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := &Engine{connMgr: &ConnMgr{force: tt.force, remoteLazyEnabled: tt.remoteEnabled}}
|
||||||
|
got := e.toExcludedLazyPeers(peers)
|
||||||
|
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("toExcludedLazyPeers() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
for k := range tt.want {
|
||||||
|
if !got[k] {
|
||||||
|
t.Fatalf("expected peer %s excluded, got %v", k, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+14
-26
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Package elevate re-runs this very executable under the operating system's own
|
||||||
|
// privilege-elevation mechanism and waits for it to finish.
|
||||||
|
//
|
||||||
|
// It exists so that a change the daemon restricts to root/administrator can be
|
||||||
|
// authorized from the GUI, by the user, at the moment they ask for it: Windows
|
||||||
|
// shows the UAC consent dialog, macOS the system authentication dialog, and
|
||||||
|
// Linux/FreeBSD the session's polkit agent. The credentials, where any are
|
||||||
|
// asked for, are collected by the operating system and never pass through
|
||||||
|
// NetBird.
|
||||||
|
//
|
||||||
|
// What the elevated process then does is the caller's business: it is the same
|
||||||
|
// binary, in a one-shot mode, and it is authorized by the daemon exactly like
|
||||||
|
// any other privileged caller, from the identity the kernel reports on the
|
||||||
|
// control channel. Nothing here grants privilege, and the daemon gains no new
|
||||||
|
// way to be talked into something: elevation only changes who is calling it.
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppliedMarker is what the elevated process prints on standard output once it has
|
||||||
|
// done what it was run for.
|
||||||
|
//
|
||||||
|
// macOS's AuthorizationExecuteWithPrivileges reports no exit status and does not
|
||||||
|
// say which process it started, so there this line is the only evidence that the
|
||||||
|
// change was applied. The other platforms have an exit code and ignore it.
|
||||||
|
const AppliedMarker = "netbird-elevated: applied"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrDeclined reports that the user dismissed the prompt or did not
|
||||||
|
// authenticate. Nothing happened and nothing is wrong: a caller undoes its
|
||||||
|
// optimistic update and stays quiet.
|
||||||
|
ErrDeclined = errors.New("authorization declined")
|
||||||
|
|
||||||
|
// ErrUnavailable reports that this host has no elevation mechanism we can
|
||||||
|
// drive: no polkit on a Unix desktop, or an executable we decline to run as
|
||||||
|
// root. A caller falls back to telling the user which command to run.
|
||||||
|
ErrUnavailable = errors.New("no privilege elevation mechanism available")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run runs this executable with args under the platform's elevation mechanism
|
||||||
|
// and waits for it to exit. A non-zero exit is returned as an error, so the
|
||||||
|
// caller can treat a completed Run as the operation having succeeded.
|
||||||
|
//
|
||||||
|
// The args are the caller's own command line, so they cross no privilege
|
||||||
|
// boundary: only a user who has just authenticated as an administrator can get
|
||||||
|
// them run at all.
|
||||||
|
func Run(ctx context.Context, args ...string) error {
|
||||||
|
self, err := trustedSelf()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return run(ctx, self, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available reports whether Run has a mechanism to use on this host, so a caller
|
||||||
|
// can offer the prompt only when there is one and otherwise fall back to
|
||||||
|
// guidance the user can act on. It answers from what is installed, not from what
|
||||||
|
// the user is allowed to do: an administrator's password may still be required
|
||||||
|
// and may still not be given, which is ErrDeclined from Run.
|
||||||
|
func Available() bool {
|
||||||
|
if _, err := trustedSelf(); err != nil {
|
||||||
|
// Worth a line: this is also what a build run from a group-writable
|
||||||
|
// directory hits, and there is nothing in the UI to say why the offer is
|
||||||
|
// missing.
|
||||||
|
log.Debugf("not offering privilege elevation: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mechanismAvailable()
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// noOutput stands in for a process that said nothing, so that a report of what it
|
||||||
|
// said still reads as a sentence.
|
||||||
|
const noOutput = "no output"
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return noOutput
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFirstLine(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{in: "", want: noOutput},
|
||||||
|
{in: " \n ", want: noOutput},
|
||||||
|
{in: "one line", want: "one line"},
|
||||||
|
{in: "first\nsecond", want: "first"},
|
||||||
|
{in: "\nsecond\n", want: "second"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, firstLine(tt.in), "input %q", tt.in)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/ebitengine/purego"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authorization Services, reached through purego rather than cgo so the released
|
||||||
|
// binaries keep building with CGO_ENABLED=0.
|
||||||
|
//
|
||||||
|
// The prompt belongs to this process, which is what makes it carry the
|
||||||
|
// application's name and our own explanation. Going through osascript instead puts
|
||||||
|
// the very same trampoline behind a dialog attributed to osascript, and means
|
||||||
|
// handing a shell a command line to re-parse.
|
||||||
|
//
|
||||||
|
// # On AuthorizationExecuteWithPrivileges
|
||||||
|
//
|
||||||
|
// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on
|
||||||
|
// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's
|
||||||
|
// been deprecated for many years. Do not use it in a widely distributed product."
|
||||||
|
// It is used here anyway, knowingly, because the alternatives Apple offers are for
|
||||||
|
// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless —
|
||||||
|
// and NetBird already has what they would install: a launchd daemon running as
|
||||||
|
// root. What is missing is only a way for an unprivileged client to ask it to act.
|
||||||
|
//
|
||||||
|
// The way to that without a deprecated call is to authorize the client instead of
|
||||||
|
// elevating one: the app takes the right with AuthorizationCreate, passes the
|
||||||
|
// AuthorizationExternalForm to the daemon, and the daemon checks it with
|
||||||
|
// AuthorizationCopyRights before acting — none of which is deprecated. It is the
|
||||||
|
// better design and it is where this should end up. It also means the daemon
|
||||||
|
// accepting an authorization over its control socket, which is a new way to be
|
||||||
|
// asked for privileged work and wants reviewing as such, so it is deliberately not
|
||||||
|
// bundled in with the rest of this.
|
||||||
|
//
|
||||||
|
// Until then, three things keep the deprecation from being a trap. Every symbol is
|
||||||
|
// resolved with an error rather than a panic, so a macOS that has dropped this
|
||||||
|
// function leaves the app offering the user a command instead of crashing on the
|
||||||
|
// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the
|
||||||
|
// fallback is the same one an agent-less Linux session gets. And the whole path
|
||||||
|
// runs under guard, which turns a panic out of the FFI layer into that same
|
||||||
|
// fallback.
|
||||||
|
//
|
||||||
|
// The trampoline passes on the environment it was given, so what it starts as root
|
||||||
|
// must be an executable this user's peers cannot influence: that is what
|
||||||
|
// trustedSelf refuses, and what signing the binary settles for the loader.
|
||||||
|
|
||||||
|
const (
|
||||||
|
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
|
||||||
|
libSystem = "/usr/lib/libSystem.B.dylib"
|
||||||
|
|
||||||
|
// trampoline is what the framework hands the tool to. Present on every macOS,
|
||||||
|
// and worth confirming before offering a prompt rather than mid-prompt.
|
||||||
|
trampoline = "/usr/libexec/security_authtrampoline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rightExecute is the right an administrator holds, and what
|
||||||
|
// AuthorizationExecuteWithPrivileges requires of us.
|
||||||
|
const rightExecute = "system.privilege.admin"
|
||||||
|
|
||||||
|
// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above
|
||||||
|
// the system's in the dialog. It is about the change rather than the mechanism.
|
||||||
|
const (
|
||||||
|
promptKey = "prompt"
|
||||||
|
promptText = "NetBird needs to change a setting that grants SSH access to this computer."
|
||||||
|
)
|
||||||
|
|
||||||
|
// OSStatus values from SecBase.h that mean something to us; anything else is
|
||||||
|
// reported as it comes.
|
||||||
|
const (
|
||||||
|
errAuthorizationSuccess = 0
|
||||||
|
errAuthorizationDenied = -60005
|
||||||
|
errAuthorizationCanceled = -60006
|
||||||
|
errAuthorizationInteractionNotAllowed = -60007
|
||||||
|
errAuthorizationToolExecuteFailure = -60031
|
||||||
|
errAuthorizationToolEnvironmentError = -60032
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthorizationFlags from Authorization.h.
|
||||||
|
const (
|
||||||
|
flagDefaults = 0
|
||||||
|
flagInteractionAllowed = 1 << 0
|
||||||
|
flagExtendRights = 1 << 1
|
||||||
|
flagDestroyRights = 1 << 3
|
||||||
|
flagPreAuthorize = 1 << 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives
|
||||||
|
// meaning to. 32 bytes on both amd64 and arm64.
|
||||||
|
type authorizationItem struct {
|
||||||
|
name *byte
|
||||||
|
valueLength uintptr
|
||||||
|
value unsafe.Pointer
|
||||||
|
// flags is reserved by the API and always zero. Declared because the layout
|
||||||
|
// is the contract: without it the struct is 24 bytes where C reads 32.
|
||||||
|
flags uint32 //nolint:unused // part of the C layout
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an
|
||||||
|
// AuthorizationRights and an AuthorizationEnvironment.
|
||||||
|
type authorizationItemSet struct {
|
||||||
|
count uint32
|
||||||
|
items *authorizationItem
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32
|
||||||
|
authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32
|
||||||
|
authorizationFree func(authorization uintptr, flags uint32) int32
|
||||||
|
fileno func(stream uintptr) int32
|
||||||
|
fclose func(stream uintptr) int32
|
||||||
|
|
||||||
|
loadOnce sync.Once
|
||||||
|
loadErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// load resolves the functions once. A framework that cannot be opened, or a symbol
|
||||||
|
// that is no longer there, leaves the host without a mechanism rather than taking
|
||||||
|
// the process down with it: see the note on deprecation above.
|
||||||
|
func load() error {
|
||||||
|
loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) })
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard turns a panic out of the FFI layer into an error, so an API that has
|
||||||
|
// changed under us costs the user a prompt rather than the window they were
|
||||||
|
// clicking in. purego panics on a signature it cannot map, and this is the one
|
||||||
|
// place in the client that calls a deprecated system function.
|
||||||
|
//
|
||||||
|
// It catches Go panics, which is what purego raises. A fault inside the framework
|
||||||
|
// itself is not a panic and not recoverable; the layout the tests pin down is what
|
||||||
|
// stands between us and that.
|
||||||
|
func guard(what string, fn func() error) (err error) {
|
||||||
|
defer func() {
|
||||||
|
r := recover()
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Errorf("%s panicked: %v", what, r)
|
||||||
|
err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r)
|
||||||
|
}()
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve() error {
|
||||||
|
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", securityFramework, err)
|
||||||
|
}
|
||||||
|
system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", libSystem, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a
|
||||||
|
// deprecated function's disappearance should reach the user.
|
||||||
|
for _, fn := range []struct {
|
||||||
|
ptr any
|
||||||
|
handle uintptr
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{&authorizationCreate, security, "AuthorizationCreate"},
|
||||||
|
{&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"},
|
||||||
|
{&authorizationFree, security, "AuthorizationFree"},
|
||||||
|
{&fileno, system, "fileno"},
|
||||||
|
{&fclose, system, "fclose"},
|
||||||
|
} {
|
||||||
|
symbol, err := purego.Dlsym(fn.handle, fn.name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve %s: %w", fn.name, err)
|
||||||
|
}
|
||||||
|
if symbol == 0 {
|
||||||
|
return fmt.Errorf("resolve %s: not present on this system", fn.name)
|
||||||
|
}
|
||||||
|
purego.RegisterFunc(fn.ptr, symbol)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// run asks the system to run self as root: first for the right, which is what puts
|
||||||
|
// up the authentication dialog and collects the password or takes the Touch ID,
|
||||||
|
// then for the tool. The credentials go to the system's authorization trampoline
|
||||||
|
// and never to us.
|
||||||
|
//
|
||||||
|
// The context bounds only our own waiting; the dialog belongs to the system and
|
||||||
|
// closes when the user answers it.
|
||||||
|
func run(ctx context.Context, self string, args []string) error {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return guard("asking for privileges", func() error {
|
||||||
|
authorization, err := authorize()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer authorizationFree(authorization, flagDestroyRights)
|
||||||
|
|
||||||
|
return execute(ctx, authorization, self, args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(trampoline)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorize obtains the right, prompting for it. A dismissed dialog comes back as
|
||||||
|
// errAuthorizationCanceled and a password given up on as errAuthorizationDenied;
|
||||||
|
// both are the user's answer rather than a failure.
|
||||||
|
func authorize() (uintptr, error) {
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
||||||
|
environment := itemSet(&pinner, promptItem(&pinner))
|
||||||
|
|
||||||
|
var authorization uintptr
|
||||||
|
status := authorizationCreate(rights, environment,
|
||||||
|
flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errAuthorizationSuccess:
|
||||||
|
return authorization, nil
|
||||||
|
case errAuthorizationCanceled, errAuthorizationDenied:
|
||||||
|
return 0, ErrDeclined
|
||||||
|
case errAuthorizationInteractionNotAllowed:
|
||||||
|
// Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or
|
||||||
|
// a session with no window server.
|
||||||
|
return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable)
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute runs the tool with the right in hand and waits for it by reading the pipe
|
||||||
|
// it is given until the tool closes it.
|
||||||
|
//
|
||||||
|
// AuthorizationExecuteWithPrivileges reports no exit status and does not say what
|
||||||
|
// process it started, which is why the one-shot says so itself: what it prints is
|
||||||
|
// the only evidence that the change was applied.
|
||||||
|
func execute(ctx context.Context, authorization uintptr, self string, args []string) error {
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
argv := make([]uintptr, 0, len(args)+1)
|
||||||
|
for _, arg := range args {
|
||||||
|
argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg))))
|
||||||
|
}
|
||||||
|
argv = append(argv, 0)
|
||||||
|
pinner.Pin(&argv[0])
|
||||||
|
|
||||||
|
var pipe uintptr
|
||||||
|
status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe)
|
||||||
|
switch status {
|
||||||
|
case errAuthorizationSuccess:
|
||||||
|
case errAuthorizationCanceled:
|
||||||
|
return ErrDeclined
|
||||||
|
case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError:
|
||||||
|
// The right was granted and the tool still did not start. Nothing the user
|
||||||
|
// can do about it from here, so point them at the command instead.
|
||||||
|
return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("run %s elevated: OSStatus %d", self, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := readPipe(ctx, pipe)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkApplied(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkApplied reads the one-shot's report, which stands in for the exit status
|
||||||
|
// there is no way to ask for here. A run that said nothing did not apply the
|
||||||
|
// change, whatever else went on.
|
||||||
|
func checkApplied(out string) error {
|
||||||
|
if !strings.Contains(out, AppliedMarker) {
|
||||||
|
return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPipe drains the tool's output, which ends when the tool exits and is
|
||||||
|
// therefore also how we wait for it.
|
||||||
|
func readPipe(ctx context.Context, pipe uintptr) (string, error) {
|
||||||
|
if pipe == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
defer fclose(pipe)
|
||||||
|
|
||||||
|
fd := int(fileno(pipe))
|
||||||
|
if fd < 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
n, err := syscall.Read(fd, buf)
|
||||||
|
if n > 0 {
|
||||||
|
out.Write(buf[:n])
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, syscall.EINTR):
|
||||||
|
// A signal landed mid-read, which says nothing about the tool.
|
||||||
|
continue
|
||||||
|
case err != nil:
|
||||||
|
log.Debugf("read the elevated process's output: %v", err)
|
||||||
|
return out.String(), nil
|
||||||
|
case n <= 0:
|
||||||
|
// End of file: the tool closed the pipe, which is how it exiting
|
||||||
|
// reaches us.
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemSet builds an AuthorizationItemSet over items, pinned for the call.
|
||||||
|
func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet {
|
||||||
|
pinner.Pin(&items[0])
|
||||||
|
set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]}
|
||||||
|
pinner.Pin(set)
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptItem is the environment entry carrying our sentence for the dialog.
|
||||||
|
func promptItem(pinner *runtime.Pinner) authorizationItem {
|
||||||
|
value := []byte(promptText)
|
||||||
|
pinner.Pin(&value[0])
|
||||||
|
return authorizationItem{
|
||||||
|
name: cString(pinner, promptKey),
|
||||||
|
valueLength: uintptr(len(value)),
|
||||||
|
value: unsafe.Pointer(&value[0]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for
|
||||||
|
// the duration of the call.
|
||||||
|
func cString(pinner *runtime.Pinner, s string) *byte {
|
||||||
|
b := append([]byte(s), 0)
|
||||||
|
pinner.Pin(&b[0])
|
||||||
|
return &b[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The framework has to load and the symbols have to resolve, or nothing else here
|
||||||
|
// means anything.
|
||||||
|
func TestSecurityFrameworkLoads(t *testing.T) {
|
||||||
|
require.NoError(t, load(), "Security.framework must open")
|
||||||
|
|
||||||
|
for name, fn := range map[string]any{
|
||||||
|
"AuthorizationCreate": authorizationCreate,
|
||||||
|
"AuthorizationExecuteWithPrivileges": authorizationExecuteWithPrivileges,
|
||||||
|
"AuthorizationFree": authorizationFree,
|
||||||
|
"fileno": fileno,
|
||||||
|
"fclose": fclose,
|
||||||
|
} {
|
||||||
|
assert.NotNil(t, fn, "%s must resolve", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request with no interaction allowed exercises the whole call — the rights and
|
||||||
|
// environment structs, and the OSStatus that comes back — without a dialog anybody
|
||||||
|
// has to answer. What the system decides is its business; that it decides at all is
|
||||||
|
// what this asserts.
|
||||||
|
func TestAuthorizationCreateWithoutInteraction(t *testing.T) {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
t.Skipf("Security.framework did not open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
||||||
|
environment := itemSet(&pinner, promptItem(&pinner))
|
||||||
|
require.EqualValues(t, 1, rights.count, "the rights struct layout must match the C one")
|
||||||
|
|
||||||
|
var authorization uintptr
|
||||||
|
status := authorizationCreate(rights, environment, flagDefaults|flagExtendRights, &authorization)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errAuthorizationSuccess:
|
||||||
|
// Credentials were already cached for this session.
|
||||||
|
authorizationFree(authorization, flagDestroyRights)
|
||||||
|
case errAuthorizationDenied, errAuthorizationInteractionNotAllowed:
|
||||||
|
// The expected answers when nobody may be asked.
|
||||||
|
default:
|
||||||
|
require.Failf(t, "unknown OSStatus", "AuthorizationCreate returned %d, want a status we recognise", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asking with a right nobody has must not be mistaken for a declined prompt: the
|
||||||
|
// caller would report nothing at all.
|
||||||
|
func TestAuthorizeUnknownRightIsNotDeclined(t *testing.T) {
|
||||||
|
if err := load(); err != nil {
|
||||||
|
t.Skipf("Security.framework did not open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinner runtime.Pinner
|
||||||
|
defer pinner.Unpin()
|
||||||
|
|
||||||
|
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, "io.netbird.right.that.does.not.exist")})
|
||||||
|
|
||||||
|
var authorization uintptr
|
||||||
|
status := authorizationCreate(rights, nil, flagDefaults|flagExtendRights, &authorization)
|
||||||
|
if status == errAuthorizationSuccess {
|
||||||
|
authorizationFree(authorization, flagDestroyRights)
|
||||||
|
}
|
||||||
|
assert.NotEqual(t, int32(errAuthorizationSuccess), status, "a right that does not exist must not be granted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMechanismAvailable(t *testing.T) {
|
||||||
|
assert.True(t, mechanismAvailable(), "the trampoline exists on every macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-shot's report is what stands in for an exit status here, so a run that
|
||||||
|
// says nothing must not read as success.
|
||||||
|
func TestCheckApplied(t *testing.T) {
|
||||||
|
require.NoError(t, checkApplied(AppliedMarker+"\n"), "the report the one-shot prints")
|
||||||
|
require.NoError(t, checkApplied("some warning\n"+AppliedMarker+"\n"), "the report after other output")
|
||||||
|
|
||||||
|
assert.Error(t, checkApplied(""), "a run that printed nothing did not apply the change")
|
||||||
|
assert.Error(t, checkApplied("dyld: library not loaded\n"), "output that is not the report")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A panic out of the FFI layer has to reach the caller as "no mechanism", which is
|
||||||
|
// the outcome that offers the user the command instead of taking the window down.
|
||||||
|
func TestGuardTurnsAPanicIntoUnavailable(t *testing.T) {
|
||||||
|
err := guard("pretending to call something", func() error {
|
||||||
|
panic("purego: signature it cannot map")
|
||||||
|
})
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, ErrUnavailable, "a panic must read as a missing mechanism")
|
||||||
|
assert.Contains(t, err.Error(), "pretending to call something", "what panicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard wraps every darwin path, so what a caller switches on has to survive it.
|
||||||
|
func TestGuardPassesErrorsThrough(t *testing.T) {
|
||||||
|
sentinel := errors.New("the call itself failed")
|
||||||
|
assert.ErrorIs(t, guard("calling", func() error { return sentinel }), sentinel,
|
||||||
|
"the error it was given")
|
||||||
|
assert.ErrorIs(t, guard("calling", func() error { return ErrDeclined }), ErrDeclined,
|
||||||
|
"a declined prompt stays declined")
|
||||||
|
assert.NoError(t, guard("calling", func() error { return nil }), "a call that worked")
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pkexec exit codes that are about the authorization rather than about the program
|
||||||
|
// we asked it to run. The manual page reserves both.
|
||||||
|
const (
|
||||||
|
// exitDismissed is returned when the user dismissed the authentication
|
||||||
|
// dialog.
|
||||||
|
exitDismissed = 126
|
||||||
|
// exitNotAuthorized is returned when the authorization was not obtained. That
|
||||||
|
// covers the user saying no as well as pkexec having had nobody to ask: see
|
||||||
|
// noAgentMarkers.
|
||||||
|
exitNotAuthorized = 127
|
||||||
|
)
|
||||||
|
|
||||||
|
// exitNotAuthorized covers three different endings that only pkexec's own words
|
||||||
|
// tell apart, so they are matched here. Read with LC_ALL=C so the words are the
|
||||||
|
// ones written below.
|
||||||
|
//
|
||||||
|
// refusedMarker is a refusal: the user said no, gave up on the password, or holds
|
||||||
|
// an account that may not elevate at all.
|
||||||
|
const refusedMarker = "Not authorized"
|
||||||
|
|
||||||
|
// noAgentMarkers say pkexec had no way to ask: no agent registered for the
|
||||||
|
// session, and no controlling terminal for the textual agent it falls back to.
|
||||||
|
var noAgentMarkers = []string{"authentication agent", "controlling terminal"}
|
||||||
|
|
||||||
|
// run asks polkit to run self as root. pkexec hands the request to the session's
|
||||||
|
// polkit agent, which is what prompts and what collects any password; we see only
|
||||||
|
// its verdict.
|
||||||
|
//
|
||||||
|
// The environment is otherwise deliberately not passed through: pkexec clears it
|
||||||
|
// bar a small allowlist, and the one-shot needs nothing from it.
|
||||||
|
func run(ctx context.Context, self string, args []string) error {
|
||||||
|
pkexec, err := exec.LookPath("pkexec")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: pkexec is not installed", ErrUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, pkexec, append([]string{self}, args...)...)
|
||||||
|
// C locale so pkexec's own diagnostics are the ones noAgentMarkers knows.
|
||||||
|
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
// The one-shot reports itself on stdout for macOS's sake, where there is no
|
||||||
|
// exit status to read. Here there is one, so that line is noise.
|
||||||
|
cmd.Stdout = io.Discard
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
return fmt.Errorf("run pkexec: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matched against everything pkexec said, reported as one line: a complaint
|
||||||
|
// that is not the first thing printed still has to be recognised, and reading
|
||||||
|
// it as a refusal would swallow it.
|
||||||
|
full := stderr.String()
|
||||||
|
out := firstLine(full)
|
||||||
|
|
||||||
|
switch exitErr.ExitCode() {
|
||||||
|
case exitDismissed:
|
||||||
|
return ErrDeclined
|
||||||
|
case exitNotAuthorized:
|
||||||
|
return notAuthorized(full, out)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("elevated netbird exited with %d: %s", exitErr.ExitCode(), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notAuthorized sorts out the three endings pkexec reports as exitNotAuthorized.
|
||||||
|
//
|
||||||
|
// It also returns that code when the authorization succeeded and it then could
|
||||||
|
// not run the program, so a refusal has to be recognised rather than assumed:
|
||||||
|
// reading every one of these as "the user said no" would revert the control in
|
||||||
|
// silence on a host where elevation is broken.
|
||||||
|
func notAuthorized(full, out string) error {
|
||||||
|
switch {
|
||||||
|
case hasAny(full, noAgentMarkers):
|
||||||
|
return fmt.Errorf("%w: polkit had no way to ask: %s", ErrUnavailable, out)
|
||||||
|
case out == noOutput, strings.Contains(full, refusedMarker):
|
||||||
|
// The user said no, which needs no message; that an account barred from
|
||||||
|
// elevating altogether lands here too is why the reason is kept.
|
||||||
|
return fmt.Errorf("%w: %s", ErrDeclined, out)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("pkexec could not run elevated netbird: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAny(s string, markers []string) bool {
|
||||||
|
for _, marker := range markers {
|
||||||
|
if strings.Contains(s, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
_, err := exec.LookPath("pkexec")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePkexec puts a pkexec on PATH that exits with the given code, so the
|
||||||
|
// mapping from polkit's exit codes onto our errors can be exercised without a
|
||||||
|
// polkit agent.
|
||||||
|
func fakePkexec(t *testing.T, exitCode int, stderr string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
script := fmt.Sprintf("#!/bin/sh\necho %s >&2\nexit %d\n", shellQuote(stderr), exitCode)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "pkexec"), []byte(script), 0o700), "write the fake pkexec")
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMapsPkexecExitCodes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
exitCode int
|
||||||
|
stderr string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{name: "applied", exitCode: 0},
|
||||||
|
{
|
||||||
|
name: "dialog dismissed",
|
||||||
|
exitCode: exitDismissed,
|
||||||
|
stderr: "Error executing command as another user: Request dismissed",
|
||||||
|
wantErr: ErrDeclined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// What a graphical agent reports for a cancelled prompt. Not a
|
||||||
|
// failure: the user was asked and answered.
|
||||||
|
name: "prompt cancelled",
|
||||||
|
exitCode: exitNotAuthorized,
|
||||||
|
stderr: "Error executing command as another user: Not authorized",
|
||||||
|
wantErr: ErrDeclined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The same status, but pkexec never got to ask anybody.
|
||||||
|
name: "no agent and no terminal to fall back on",
|
||||||
|
exitCode: exitNotAuthorized,
|
||||||
|
stderr: "Error creating textual authentication agent: Error opening current controlling terminal for the process (`/dev/tty'): No such device or address",
|
||||||
|
wantErr: ErrUnavailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// And the same status again once the authorization succeeded and
|
||||||
|
// pkexec could not run what it had been authorized to run. Reading
|
||||||
|
// that as a refusal would revert the control in silence on a host
|
||||||
|
// where elevation is broken.
|
||||||
|
name: "authorized but not runnable",
|
||||||
|
exitCode: exitNotAuthorized,
|
||||||
|
stderr: "Error executing command as another user: No such file or directory",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fakePkexec(t, tt.exitCode, tt.stderr)
|
||||||
|
|
||||||
|
err := run(context.Background(), "/nonexistent/netbird-ui", []string{"--flag"})
|
||||||
|
switch {
|
||||||
|
case tt.wantErr != nil:
|
||||||
|
require.ErrorIs(t, err, tt.wantErr, "exit %d said %q", tt.exitCode, tt.stderr)
|
||||||
|
case tt.exitCode == 0:
|
||||||
|
require.NoError(t, err, "a pkexec that exited cleanly applied the change")
|
||||||
|
default:
|
||||||
|
require.Error(t, err, "exit %d said %q", tt.exitCode, tt.stderr)
|
||||||
|
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
|
||||||
|
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exit code that is not polkit's is the one-shot's own failure, and has to
|
||||||
|
// stay distinguishable from a declined prompt: the caller reports it.
|
||||||
|
func TestRunReportsOneShotFailure(t *testing.T) {
|
||||||
|
fakePkexec(t, 3, "the one-shot said no")
|
||||||
|
|
||||||
|
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
|
||||||
|
|
||||||
|
require.Error(t, err, "a one-shot that failed is not a prompt that was answered")
|
||||||
|
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
|
||||||
|
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithoutPkexecIsUnavailable(t *testing.T) {
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
|
||||||
|
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
|
||||||
|
require.ErrorIs(t, err, ErrUnavailable, "no pkexec means no mechanism")
|
||||||
|
assert.False(t, mechanismAvailable(), "mechanismAvailable without pkexec on PATH")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows && !darwin && !linux
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// run reports that this platform has no elevation prompt to drive.
|
||||||
|
//
|
||||||
|
// The desktop app is the only caller and is not built for any of these: mobile
|
||||||
|
// and WASM have no local user to ask, and the FreeBSD client ships without a UI.
|
||||||
|
// pkexec would be the mechanism there, and run_unix.go is what to widen if that
|
||||||
|
// changes.
|
||||||
|
func run(context.Context, string, []string) error {
|
||||||
|
return ErrUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// seeMaskNoCloseProcess keeps the started process's handle open in
|
||||||
|
// hProcess so we can wait for it.
|
||||||
|
seeMaskNoCloseProcess = 0x00000040
|
||||||
|
// seeMaskNoAsync makes ShellExecuteExW finish its work before returning,
|
||||||
|
// which it must when the calling thread does not pump messages.
|
||||||
|
seeMaskNoAsync = 0x00000100
|
||||||
|
// seeMaskFlagNoUI suppresses the shell's own error dialogs; the UAC consent
|
||||||
|
// dialog is not one of them and still appears.
|
||||||
|
seeMaskFlagNoUI = 0x00000400
|
||||||
|
|
||||||
|
// swHide: the one-shot has no window to show.
|
||||||
|
swHide = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// shellExecuteInfoW mirrors SHELLEXECUTEINFOW. The field order and Go's own
|
||||||
|
// padding match the C layout on both 386 and amd64.
|
||||||
|
type shellExecuteInfoW struct {
|
||||||
|
cbSize uint32
|
||||||
|
fMask uint32
|
||||||
|
hwnd windows.HWND
|
||||||
|
lpVerb *uint16
|
||||||
|
lpFile *uint16
|
||||||
|
lpParameters *uint16
|
||||||
|
lpDirectory *uint16
|
||||||
|
nShow int32
|
||||||
|
hInstApp windows.Handle
|
||||||
|
lpIDList uintptr
|
||||||
|
lpClass *uint16
|
||||||
|
hkeyClass windows.Handle
|
||||||
|
dwHotKey uint32
|
||||||
|
hIconOrMonitor windows.Handle
|
||||||
|
hProcess windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
shell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||||
|
procShellExecuteEx = shell32.NewProc("ShellExecuteExW")
|
||||||
|
)
|
||||||
|
|
||||||
|
// run starts self elevated with the "runas" verb, which is what raises the UAC
|
||||||
|
// consent dialog, and waits for it to finish. Windows decides whether consent is
|
||||||
|
// enough or an administrator's credentials are needed, and collects them itself.
|
||||||
|
func run(ctx context.Context, self string, args []string) error {
|
||||||
|
verb, err := windows.UTF16PtrFromString("runas")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode verb: %w", err)
|
||||||
|
}
|
||||||
|
file, err := windows.UTF16PtrFromString(self)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode %s: %w", self, err)
|
||||||
|
}
|
||||||
|
params, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := shellExecuteInfoW{
|
||||||
|
fMask: seeMaskNoCloseProcess | seeMaskNoAsync | seeMaskFlagNoUI,
|
||||||
|
hwnd: ownerWindow(),
|
||||||
|
lpVerb: verb,
|
||||||
|
lpFile: file,
|
||||||
|
lpParameters: params,
|
||||||
|
nShow: swHide,
|
||||||
|
}
|
||||||
|
info.cbSize = uint32(unsafe.Sizeof(info))
|
||||||
|
|
||||||
|
process, err := shellExecute(&info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := windows.CloseHandle(process); err != nil {
|
||||||
|
log.Debugf("close elevated process handle: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return waitForProcess(ctx, process)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellExecute performs the call itself. ShellExecuteExW wants COM initialised on
|
||||||
|
// the calling thread, so the goroutine is pinned to one for the duration and COM
|
||||||
|
// is set up on it; an "already initialised, different mode" answer is fine,
|
||||||
|
// because then somebody else has done it for us.
|
||||||
|
func shellExecute(info *shellExecuteInfoW) (windows.Handle, error) {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
|
||||||
|
switch err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); {
|
||||||
|
case err == nil, isHResult(err, windows.S_FALSE):
|
||||||
|
// Ours, or already initialised in the same mode: either way this call
|
||||||
|
// counts and has to be balanced.
|
||||||
|
defer windows.CoUninitialize()
|
||||||
|
case isHResult(err, windows.RPC_E_CHANGED_MODE):
|
||||||
|
// The thread is already in the other apartment model. ShellExecuteExW
|
||||||
|
// works there too, and there is nothing of ours to balance.
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("initialise COM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ret, _, lastErr := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info)))
|
||||||
|
if ret != 0 {
|
||||||
|
return info.hProcess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(lastErr, windows.ERROR_CANCELLED) {
|
||||||
|
return 0, ErrDeclined
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("run elevated: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerWindow returns this process's foreground window, and 0 when the window in
|
||||||
|
// front belongs to somebody else or cannot be attributed. ShellExecuteExW takes it
|
||||||
|
// as the parent for the UI it raises, which is what keeps the consent dialog in
|
||||||
|
// front of the window the user was just clicking in instead of behind it. It is
|
||||||
|
// also what a remote-desktop session needs to place the dialog at all when the
|
||||||
|
// secure desktop is switched off.
|
||||||
|
func ownerWindow() windows.HWND {
|
||||||
|
hwnd := windows.GetForegroundWindow()
|
||||||
|
if hwnd == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var pid uint32
|
||||||
|
if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil {
|
||||||
|
log.Debugf("cannot attribute the foreground window, raising the prompt without an owner: %v", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if pid != windows.GetCurrentProcessId() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return hwnd
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHResult reports whether err carries the given HRESULT. CoInitializeEx
|
||||||
|
// returns its HRESULT as an Errno, so the comparison is on the raw value.
|
||||||
|
func isHResult(err error, hresult windows.Handle) bool {
|
||||||
|
var errno windows.Errno
|
||||||
|
return errors.As(err, &errno) && uintptr(errno) == uintptr(hresult)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForProcess(ctx context.Context, process windows.Handle) error {
|
||||||
|
// The wait is interruptible so a cancelled context stops us waiting on a
|
||||||
|
// consent dialog nobody is going to answer. The elevated process is not
|
||||||
|
// ours to kill, and it either applies the change or does not.
|
||||||
|
for {
|
||||||
|
event, err := windows.WaitForSingleObject(process, 250)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wait for the elevated process: %w", err)
|
||||||
|
}
|
||||||
|
if event == uint32(windows.WAIT_OBJECT_0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var code uint32
|
||||||
|
if err := windows.GetExitCodeProcess(process, &code); err != nil {
|
||||||
|
return fmt.Errorf("read the elevated process's exit code: %w", err)
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
return fmt.Errorf("elevated netbird exited with %d", code)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mechanismAvailable is true on Windows: UAC prompts for consent when the user
|
||||||
|
// is an administrator and for an administrator's credentials when they are not,
|
||||||
|
// so there is always something to ask.
|
||||||
|
func mechanismAvailable() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trustedSelf returns the path of this executable, provided it is one we are
|
||||||
|
// willing to have run as root.
|
||||||
|
//
|
||||||
|
// The check is what keeps elevation from becoming a way to launder someone
|
||||||
|
// else's code into a root process: the user consents to NetBird being elevated,
|
||||||
|
// having been shown NetBird's name, so what runs must be the file NetBird was
|
||||||
|
// installed as and not something a third party could have swapped for it. An
|
||||||
|
// executable only its owner can write is that; anything wider is refused, and
|
||||||
|
// the caller falls back to showing the command instead.
|
||||||
|
//
|
||||||
|
// The owner writing to their own executable is not part of that threat: code
|
||||||
|
// running as the user can already prompt them for anything, and could just as
|
||||||
|
// well ask them to run the command by hand. What matters is that no *other*
|
||||||
|
// unprivileged account can reach it.
|
||||||
|
func trustedSelf() (string, error) {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("locate this executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve symlinks so the checks below apply to the file that would actually
|
||||||
|
// be executed, not to a link somebody else may control.
|
||||||
|
resolved, err := filepath.EvalSymlinks(exe)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve %s: %w", exe, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkOnlyOwnerWritable(resolved); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %s cannot be trusted to run as root: %w", ErrUnavailable, resolved, err)
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
// adminWriteGIDs are the groups whose write access to an executable does not
|
||||||
|
// widen who could authorize elevating it.
|
||||||
|
//
|
||||||
|
// macOS installs applications as root:admin, mode 0775, /Applications included,
|
||||||
|
// so requiring owner-only write would reject every normal install. Group admin
|
||||||
|
// (gid 80) is exactly the set of accounts that can answer the authentication
|
||||||
|
// dialog, so its write access grants nothing the prompt would not.
|
||||||
|
var adminWriteGIDs = []uint32{0, 80}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
// adminWriteGIDs are the groups whose write access to an executable does not
|
||||||
|
// widen who could authorize elevating it. Only root's own group qualifies here:
|
||||||
|
// a distribution installs into root-owned directories, and there is no
|
||||||
|
// system-wide administrators group that both writes them and answers polkit.
|
||||||
|
var adminWriteGIDs = []uint32{0}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/getent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkOnlyOwnerWritable reports an error unless path, and every directory leading
|
||||||
|
// to it, is owned by either root or this user and writable by nobody who could not
|
||||||
|
// already act as its owner. A writable directory is as good as a writable file,
|
||||||
|
// since anything in it can be replaced, so the whole chain is checked.
|
||||||
|
func checkOnlyOwnerWritable(path string) error {
|
||||||
|
self := uint32(os.Getuid())
|
||||||
|
|
||||||
|
for dir := path; ; dir = filepath.Dir(dir) {
|
||||||
|
info, err := os.Lstat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("file ownership is unavailable on this platform")
|
||||||
|
}
|
||||||
|
if stat.Uid != 0 && stat.Uid != self {
|
||||||
|
return fmt.Errorf("%s is owned by uid %d, neither root nor this user", dir, stat.Uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkWriteBits(dir, info, stat.Uid, stat.Gid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent := filepath.Dir(dir); parent == dir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkWriteBits(path string, info os.FileInfo, uid, gid uint32) error {
|
||||||
|
// On a directory the sticky bit stands in for the write bits: whoever may
|
||||||
|
// write there still cannot replace an entry they do not own, which is the
|
||||||
|
// only thing that would matter to us. /tmp is the usual example.
|
||||||
|
sticky := info.IsDir() && info.Mode()&os.ModeSticky != 0
|
||||||
|
|
||||||
|
return writeBitsAllow(path, info.Mode().Perm(), sticky, groupWriteAllowed(uid, gid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBitsAllow decides on the permission bits alone, given whether the group's
|
||||||
|
// write access has been vouched for.
|
||||||
|
func writeBitsAllow(path string, perm os.FileMode, sticky, groupAllowed bool) error {
|
||||||
|
if sticky {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if perm&0o020 != 0 && !groupAllowed {
|
||||||
|
return fmt.Errorf("%s is writable by a group with members other than its owner (%v)", path, perm)
|
||||||
|
}
|
||||||
|
if perm&0o002 != 0 {
|
||||||
|
return fmt.Errorf("%s is world-writable (%v)", path, perm)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupWriteAllowed reports whether a group's write access to a file owned by uid
|
||||||
|
// puts it in reach of anyone who could not already act as that owner.
|
||||||
|
//
|
||||||
|
// Two ways it does not. A group in adminWriteGIDs holds the accounts that can
|
||||||
|
// answer the elevation prompt anyway. And a user private group is how Debian,
|
||||||
|
// Ubuntu and Fedora ship: their umask of 002 makes a home directory and
|
||||||
|
// everything built in it group-writable, so refusing that would refuse every
|
||||||
|
// build not installed from a package.
|
||||||
|
func groupWriteAllowed(uid, gid uint32) bool {
|
||||||
|
if slices.Contains(adminWriteGIDs, gid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := getent.LookupGroupID(strconv.FormatUint(uint64(gid), 10))
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("cannot look up group %d, treating it as shared: %v", gid, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
owner, err := getent.LookupUserID(strconv.FormatUint(uint64(uid), 10))
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("cannot look up uid %d, treating its group as shared: %v", uid, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if group.Name != owner.Username {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !groupHasOtherMembers(group.Name, owner.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupHasOtherMembers reports whether the group lists a member besides owner.
|
||||||
|
//
|
||||||
|
// Sharing the owner's name is what a user private group is recognised by, and it
|
||||||
|
// says nothing about who is in it: a group that has since gained a member is
|
||||||
|
// still named that way, and that member can write whatever the group can. So the
|
||||||
|
// membership is read rather than assumed. A group whose members cannot be
|
||||||
|
// listed, because no source on this host describes it, is treated as shared:
|
||||||
|
// the name alone cannot vouch for who writes through it.
|
||||||
|
func groupHasOtherMembers(name, owner string) bool {
|
||||||
|
members, err := getent.GroupMembers(name)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("cannot list the members of group %q, treating it as shared: %v", name, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(members, func(member string) bool { return member != owner })
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its
|
||||||
|
// numbered directory with 0777 minus the umask, so under the common 002 umask it
|
||||||
|
// is group-writable and would fail the check under test on its own.
|
||||||
|
func ownerOnlyDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory")
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeExecutable creates a plain executable file, the shape trustedSelf checks.
|
||||||
|
func writeExecutable(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, "netbird-ui")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable")
|
||||||
|
require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) {
|
||||||
|
err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t)))
|
||||||
|
assert.NoError(t, err, "an owner-only writable executable is trustworthy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) {
|
||||||
|
path := writeExecutable(t, ownerOnlyDir(t))
|
||||||
|
require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable")
|
||||||
|
|
||||||
|
assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The permission policy on its own, without a filesystem to arrange: whether the
|
||||||
|
// group has been vouched for is the only thing that makes group write acceptable.
|
||||||
|
func TestWriteBitsAllow(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
perm os.FileMode
|
||||||
|
sticky bool
|
||||||
|
groupAllowed bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "owner only", perm: 0o755},
|
||||||
|
{name: "group write in a private group", perm: 0o775, groupAllowed: true},
|
||||||
|
{name: "group write in a shared group", perm: 0o775, wantErr: true},
|
||||||
|
{name: "world write", perm: 0o777, groupAllowed: true, wantErr: true},
|
||||||
|
{name: "world write on a sticky directory", perm: 0o777, sticky: true},
|
||||||
|
{name: "group write on a sticky directory", perm: 0o775, sticky: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A build under a home directory on a distribution with a 002 umask, which is what
|
||||||
|
// a locally built or tarball-installed binary looks like. Its group has no members
|
||||||
|
// but its owner, so it is as good as owner-only.
|
||||||
|
//
|
||||||
|
// Whether this host is such a distribution is read from the environment rather than
|
||||||
|
// from groupWriteAllowed: asking the function under test whether to run would let
|
||||||
|
// it skip its own coverage away if it regressed to refusing everything.
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) {
|
||||||
|
requirePrivatePrimaryGroup(t)
|
||||||
|
|
||||||
|
dir := ownerOnlyDir(t)
|
||||||
|
path := writeExecutable(t, dir)
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable")
|
||||||
|
require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable")
|
||||||
|
|
||||||
|
err := checkOnlyOwnerWritable(path)
|
||||||
|
assert.NoError(t, err, "group write in the owner's own private group reaches nobody else")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group whose membership no source can answer for is treated as shared: the
|
||||||
|
// private-group allowance must not stand on a name nobody can vouch for. The
|
||||||
|
// membership listing itself lives in the getent package and is tested there.
|
||||||
|
func TestGroupHasOtherMembersRejectsAnUnknownGroup(t *testing.T) {
|
||||||
|
assert.True(t, groupHasOtherMembers("nonexistent_group_xyzzy_12345", "vma"),
|
||||||
|
"a group no source describes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A writable directory is as good as a writable file: whoever can write the
|
||||||
|
// directory can put a different binary at the same path.
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) {
|
||||||
|
dir := filepath.Join(ownerOnlyDir(t), "bin")
|
||||||
|
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
|
||||||
|
path := writeExecutable(t, dir)
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable")
|
||||||
|
|
||||||
|
assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sticky world-writable directory is exempt: the sticky bit is what stops one
|
||||||
|
// user replacing another's entries. /tmp is why this matters.
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) {
|
||||||
|
dir := filepath.Join(ownerOnlyDir(t), "sticky")
|
||||||
|
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
|
||||||
|
path := writeExecutable(t, dir)
|
||||||
|
require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable")
|
||||||
|
|
||||||
|
err := checkOnlyOwnerWritable(path)
|
||||||
|
assert.NoError(t, err, "the sticky bit stops another user replacing the executable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) {
|
||||||
|
err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent"))
|
||||||
|
assert.Error(t, err, "an executable that is not there must be refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// requirePrivatePrimaryGroup skips unless this user's primary group is their own,
|
||||||
|
// which is what the user-private-group allowance is about.
|
||||||
|
func requirePrivatePrimaryGroup(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
self, err := user.Current()
|
||||||
|
require.NoError(t, err, "look up the test user")
|
||||||
|
group, err := user.LookupGroupId(strconv.Itoa(os.Getgid()))
|
||||||
|
require.NoError(t, err, "look up the test user's primary group")
|
||||||
|
|
||||||
|
if group.Name != self.Username {
|
||||||
|
t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name)
|
||||||
|
}
|
||||||
|
if groupHasOtherMembers(group.Name, self.Username) {
|
||||||
|
t.Skipf("group %q has other members, so it is not a private group", group.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// fileDeleteChild is FILE_DELETE_CHILD, which x/sys does not define: the
|
||||||
|
// right to delete an entry of a directory without holding DELETE on it.
|
||||||
|
fileDeleteChild = 0x00000040
|
||||||
|
|
||||||
|
// accessAllowedCallbackACEType is an allow ACE with a condition appended to
|
||||||
|
// the ACCESS_ALLOWED_ACE layout, so its trustee is still at SidStart.
|
||||||
|
accessAllowedCallbackACEType = 0x9
|
||||||
|
|
||||||
|
// The allow ACE types that carry object GUIDs ahead of the trustee, so the
|
||||||
|
// SID is not at SidStart. They occur on directory-service objects rather
|
||||||
|
// than files, and are refused rather than skipped: see aceTrustee.
|
||||||
|
accessAllowedObjectACEType = 0x5
|
||||||
|
accessAllowedCallbackObjectACEType = 0xB
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileWriteAccess are the rights that let a trustee rewrite or replace a file,
|
||||||
|
// or take it over and then do so.
|
||||||
|
const fileWriteAccess = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA |
|
||||||
|
windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER |
|
||||||
|
windows.GENERIC_WRITE | windows.GENERIC_ALL
|
||||||
|
|
||||||
|
// dirWriteAccess are the rights over a directory that let a trustee replace an
|
||||||
|
// entry somebody else owns. Creating a new entry is not one of them, which is
|
||||||
|
// what the Unix sticky bit says in one bit: the root of every volume grants
|
||||||
|
// BUILTIN\Users the right to add directories under it, and that reaches nothing
|
||||||
|
// already there.
|
||||||
|
const dirWriteAccess = fileDeleteChild | windows.DELETE |
|
||||||
|
windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_ALL
|
||||||
|
|
||||||
|
// trustedInstallerSID owns much of what Windows itself installs. x/sys has no
|
||||||
|
// well-known constant for it.
|
||||||
|
const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
|
||||||
|
|
||||||
|
// checkOnlyOwnerWritable reports an error unless path, and every directory
|
||||||
|
// leading to it, is owned by an account that can elevate (or by this user) and
|
||||||
|
// grants write access to nobody else. A writable directory is as good as a
|
||||||
|
// writable file, since an entry in it can be replaced, so the whole chain is
|
||||||
|
// checked.
|
||||||
|
func checkOnlyOwnerWritable(path string) error {
|
||||||
|
owners, err := trustedOwners()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writers, err := trustedWriters(owners)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAccess := windows.ACCESS_MASK(fileWriteAccess)
|
||||||
|
for target := path; ; target = filepath.Dir(target) {
|
||||||
|
if err := checkSecurity(target, writeAccess, owners, writers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if parent := filepath.Dir(target); parent == target {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writeAccess = dirWriteAccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedOwners are the accounts we accept as the owner of the executable and of
|
||||||
|
// the directories above it: the ones that can already answer the UAC prompt,
|
||||||
|
// plus this user, whose own executable is theirs to write. Code running as the
|
||||||
|
// user could prompt them for anything anyway; what matters is that no *other*
|
||||||
|
// unprivileged account can reach it.
|
||||||
|
func trustedOwners() ([]*windows.SID, error) {
|
||||||
|
self, err := currentUserSID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owners := []*windows.SID{self}
|
||||||
|
for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{
|
||||||
|
windows.WinLocalSystemSid,
|
||||||
|
windows.WinBuiltinAdministratorsSid,
|
||||||
|
} {
|
||||||
|
sid, err := windows.CreateWellKnownSid(wellKnown)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build well-known SID %d: %w", wellKnown, err)
|
||||||
|
}
|
||||||
|
owners = append(owners, sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
installer, err := windows.StringToSid(trustedInstallerSID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse TrustedInstaller SID: %w", err)
|
||||||
|
}
|
||||||
|
return append(owners, installer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// trustedWriters are the trustees whose write access does not widen who could
|
||||||
|
// decide what runs behind the prompt. The owners, and CREATOR OWNER, which
|
||||||
|
// resolves to the object's owner and is therefore already vetted.
|
||||||
|
func trustedWriters(owners []*windows.SID) ([]*windows.SID, error) {
|
||||||
|
creatorOwner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build the CREATOR OWNER SID: %w", err)
|
||||||
|
}
|
||||||
|
return append(slices.Clone(owners), creatorOwner), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSecurity(path string, writeAccess windows.ACCESS_MASK, owners, writers []*windows.SID) error {
|
||||||
|
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||||
|
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read security descriptor of %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, _, err := sd.Owner()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read owner of %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if !containsSID(owners, owner) {
|
||||||
|
return fmt.Errorf("%s is owned by %s, which is neither this user nor an account that can elevate", path, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
dacl, _, err := sd.DACL()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read DACL of %s: %w", path, err)
|
||||||
|
}
|
||||||
|
// A NULL DACL grants everyone everything; only an absent security
|
||||||
|
// descriptor would have got us here without one, and neither is trustworthy.
|
||||||
|
if dacl == nil {
|
||||||
|
return fmt.Errorf("%s has no DACL, so it grants write access to everyone", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkDACL(path, dacl, writeAccess, writers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDACL refuses an ACL that grants write access to a trustee outside
|
||||||
|
// writers.
|
||||||
|
//
|
||||||
|
// An allowlist, because the trustees that must not have it cannot be listed: an
|
||||||
|
// ACE naming an ordinary user account hands that account the same power as one
|
||||||
|
// naming Everyone, and only the accounts that may hold it are knowable.
|
||||||
|
func checkDACL(path string, dacl *windows.ACL, writeAccess windows.ACCESS_MASK, writers []*windows.SID) error {
|
||||||
|
for i := uint32(0); i < uint32(dacl.AceCount); i++ {
|
||||||
|
var ace *windows.ACCESS_ALLOWED_ACE
|
||||||
|
if err := windows.GetAce(dacl, i, &ace); err != nil {
|
||||||
|
return fmt.Errorf("read ACE %d of %s: %w", i, path, err)
|
||||||
|
}
|
||||||
|
// An inherit-only ACE says what children of this object get, not what
|
||||||
|
// this object grants.
|
||||||
|
if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ace.Mask&writeAccess == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only an allow ACE grants anything; a deny ACE narrows what one gave.
|
||||||
|
if !isAllowACE(ace.Header.AceType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
trustee, err := aceTrustee(ace)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read the trustee of ACE %d of %s: %w", i, path, err)
|
||||||
|
}
|
||||||
|
if !containsSID(writers, trustee) {
|
||||||
|
return fmt.Errorf("%s grants write access to %s", path, trustee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowACE reports whether an ACE type grants rights, rather than denying,
|
||||||
|
// auditing or labelling them.
|
||||||
|
func isAllowACE(aceType uint8) bool {
|
||||||
|
switch aceType {
|
||||||
|
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType,
|
||||||
|
accessAllowedObjectACEType, accessAllowedCallbackObjectACEType:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aceTrustee returns who an allow ACE grants its rights to. An ACE whose trustee
|
||||||
|
// cannot be located is an error rather than something to skip past: being unable
|
||||||
|
// to read who is being given write access is a refusal.
|
||||||
|
func aceTrustee(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) {
|
||||||
|
switch ace.Header.AceType {
|
||||||
|
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType:
|
||||||
|
//nolint:gosec // SidStart is the first uint32 of the variable-length SID that follows the ACE header.
|
||||||
|
return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), nil
|
||||||
|
default:
|
||||||
|
return nil, errors.New("an object-type allow ACE does not carry its trustee where we can read it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSID(sids []*windows.SID, sid *windows.SID) bool {
|
||||||
|
return slices.ContainsFunc(sids, sid.Equals)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUserSID() (*windows.SID, error) {
|
||||||
|
token := windows.GetCurrentProcessToken()
|
||||||
|
user, err := token.GetTokenUser()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read this process's user: %w", err)
|
||||||
|
}
|
||||||
|
return user.User.Sid, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package elevate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A file the test user created under their own profile, which is what a per-user
|
||||||
|
// install looks like. The whole chain up to the volume root is walked, so this is
|
||||||
|
// also what says the walk does not refuse an ordinary Windows installation: the
|
||||||
|
// root of every volume grants BUILTIN\Users rights that are not ours to worry
|
||||||
|
// about.
|
||||||
|
func TestCheckOnlyOwnerWritableAcceptsOwnFile(t *testing.T) {
|
||||||
|
err := checkOnlyOwnerWritable(writeExecutable(t))
|
||||||
|
assert.NoError(t, err, "a file the test user owns, under directories only administrators can write")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write access held by an account that cannot answer the UAC prompt means that
|
||||||
|
// account decides what runs behind it, whoever the ACE names. The trustees that
|
||||||
|
// must not have it cannot be listed, so the check names the ones that may.
|
||||||
|
func TestCheckOnlyOwnerWritableRejectsUntrustedWriters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wellKnown windows.WELL_KNOWN_SID_TYPE
|
||||||
|
}{
|
||||||
|
{name: "everyone", wellKnown: windows.WinWorldSid},
|
||||||
|
{name: "authenticated users", wellKnown: windows.WinAuthenticatedUserSid},
|
||||||
|
{name: "builtin users", wellKnown: windows.WinBuiltinUsersSid},
|
||||||
|
// A service account, which no denylist of the obvious groups would name
|
||||||
|
// and which cannot elevate any more than Everyone can.
|
||||||
|
{name: "local service", wellKnown: windows.WinLocalServiceSid},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := writeExecutable(t)
|
||||||
|
grantWrite(t, path, tt.wellKnown)
|
||||||
|
|
||||||
|
assert.Error(t, checkOnlyOwnerWritable(path),
|
||||||
|
"write access for %s must be refused", tt.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The masks are the policy: on a file any write reaches its contents, while on a
|
||||||
|
// directory only deleting or taking over an entry reaches something already
|
||||||
|
// there. Adding an entry does not, which is why the walk survives a volume root.
|
||||||
|
func TestWriteAccessMasks(t *testing.T) {
|
||||||
|
assert.NotZero(t, fileWriteAccess&windows.FILE_WRITE_DATA, "writing a file's data reaches its contents")
|
||||||
|
assert.NotZero(t, fileWriteAccess&windows.FILE_APPEND_DATA, "appending to a file reaches its contents")
|
||||||
|
|
||||||
|
assert.Zero(t, dirWriteAccess&windows.FILE_WRITE_DATA, "adding a file to a directory replaces nothing")
|
||||||
|
assert.Zero(t, dirWriteAccess&windows.FILE_APPEND_DATA, "adding a subdirectory replaces nothing")
|
||||||
|
assert.NotZero(t, dirWriteAccess&fileDeleteChild, "deleting an entry replaces it")
|
||||||
|
assert.NotZero(t, dirWriteAccess&windows.DELETE, "deleting the directory takes its entries with it")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowACE(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
aceType uint8
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, want: true},
|
||||||
|
{name: "allowed callback", aceType: accessAllowedCallbackACEType, want: true},
|
||||||
|
{name: "allowed object", aceType: accessAllowedObjectACEType, want: true},
|
||||||
|
{name: "allowed callback object", aceType: accessAllowedCallbackObjectACEType, want: true},
|
||||||
|
{name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE},
|
||||||
|
// SYSTEM_AUDIT_ACE_TYPE, which x/sys does not define: an ACE that records
|
||||||
|
// access rather than granting it.
|
||||||
|
{name: "audit", aceType: 0x2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, isAllowACE(tt.aceType), "ACE type %#x", tt.aceType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeExecutable creates a plain file under the test's own directory, the shape
|
||||||
|
// trustedSelf checks.
|
||||||
|
func writeExecutable(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "netbird-ui.exe")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("MZ"), 0o755), "write the executable")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantWrite replaces the file's DACL with one that grants a well-known trustee
|
||||||
|
// everything, keeping the test user's own access so the file stays deletable.
|
||||||
|
func grantWrite(t *testing.T, path string, wellKnown windows.WELL_KNOWN_SID_TYPE) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
trustee, err := windows.CreateWellKnownSid(wellKnown)
|
||||||
|
require.NoError(t, err, "build the trustee SID")
|
||||||
|
self, err := currentUserSID()
|
||||||
|
require.NoError(t, err, "read the test user's SID")
|
||||||
|
|
||||||
|
acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{
|
||||||
|
fullControl(self, windows.TRUSTEE_IS_USER),
|
||||||
|
fullControl(trustee, windows.TRUSTEE_IS_WELL_KNOWN_GROUP),
|
||||||
|
}, nil)
|
||||||
|
require.NoError(t, err, "build the ACL")
|
||||||
|
|
||||||
|
require.NoError(t, windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||||
|
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||||
|
nil, nil, acl, nil), "set the DACL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullControl(sid *windows.SID, trusteeType uint32) windows.EXPLICIT_ACCESS {
|
||||||
|
return windows.EXPLICIT_ACCESS{
|
||||||
|
AccessPermissions: windows.GENERIC_ALL,
|
||||||
|
AccessMode: windows.GRANT_ACCESS,
|
||||||
|
Trustee: windows.TRUSTEE{
|
||||||
|
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||||
|
TrusteeType: windows.TRUSTEE_TYPE(trusteeType),
|
||||||
|
TrusteeValue: windows.TrusteeValueFromSID(sid),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-54
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package getent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Windows does not use NSS or getent; os/user resolves accounts there
|
||||||
|
// without cgo, so everything delegates to it.
|
||||||
|
|
||||||
|
// LookupUser looks up a user by name.
|
||||||
|
func LookupUser(username string) (*user.User, error) {
|
||||||
|
return user.Lookup(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupUserID looks up a user by UID.
|
||||||
|
func LookupUserID(uid string) (*user.User, error) {
|
||||||
|
return user.LookupId(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentUser returns the user this process runs as.
|
||||||
|
func CurrentUser() (*user.User, error) {
|
||||||
|
return user.Current()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupIDs returns the IDs of the groups the user is a member of.
|
||||||
|
func GroupIDs(u *user.User) ([]string, error) {
|
||||||
|
return u.GroupIds()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserShell is unanswerable on Windows, which has no login-shell database.
|
||||||
|
func UserShell(string) (string, error) {
|
||||||
|
return "", errors.ErrUnsupported
|
||||||
|
}
|
||||||
@@ -91,6 +91,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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ var allKeys = []string{
|
|||||||
KeyRosenpassEnabled,
|
KeyRosenpassEnabled,
|
||||||
KeyRosenpassPermissive,
|
KeyRosenpassPermissive,
|
||||||
KeyWireguardPort,
|
KeyWireguardPort,
|
||||||
|
KeyEnableLocalMetrics,
|
||||||
|
KeyLocalMetricsAddress,
|
||||||
KeySplitTunnelMode,
|
KeySplitTunnelMode,
|
||||||
KeySplitTunnelApps,
|
KeySplitTunnelApps,
|
||||||
KeyLazyConnection,
|
KeyLazyConnection,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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" +
|
||||||
|
|||||||
@@ -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,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
Reference in New Issue
Block a user