mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-29 10:09:07 +02:00
Merge branch 'main' into embedded-vnc
This commit is contained in:
@@ -70,6 +70,7 @@ type ConfigInput struct {
|
||||
StateFilePath string
|
||||
PreSharedKey *string
|
||||
ServerSSHAllowed *bool
|
||||
RemoteJobsAllowed *bool
|
||||
ServerVNCAllowed *bool
|
||||
DisableVNCApproval *bool
|
||||
EnableSSHRoot *bool
|
||||
@@ -129,6 +130,7 @@ type Config struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed *bool
|
||||
RemoteJobsAllowed *bool
|
||||
ServerVNCAllowed *bool
|
||||
DisableVNCApproval *bool
|
||||
EnableSSHRoot *bool
|
||||
@@ -196,6 +198,12 @@ type Config struct {
|
||||
// Runtime-only: re-derived from MDM policy on each load, never persisted.
|
||||
LazyConnection string `json:"-"`
|
||||
|
||||
// DebugBundleUploadURL is the MDM-managed debug-bundle upload URL override.
|
||||
// When set, it takes precedence over the management-supplied upload URL for
|
||||
// remote debug bundle jobs. Runtime-only: re-derived from MDM policy on each
|
||||
// load, never persisted.
|
||||
DebugBundleUploadURL string `json:"-"`
|
||||
|
||||
MTU uint16
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values for
|
||||
@@ -229,6 +237,12 @@ func getConfigDir() (string, error) {
|
||||
}
|
||||
|
||||
configDir := filepath.Join(base, "netbird")
|
||||
// Under sudo this is the invoking user's directory and strictly read-only:
|
||||
// anything root creates in it would be root-owned and break the user's own
|
||||
// runs. Reads of a missing directory fall through to defaults.
|
||||
if sudoActive() {
|
||||
return configDir, nil
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -236,6 +250,16 @@ func getConfigDir() (string, error) {
|
||||
}
|
||||
|
||||
func baseConfigDir() (string, error) {
|
||||
if u, ok := sudoInvokingUser(); ok {
|
||||
return userBaseConfigDir(u)
|
||||
}
|
||||
// Fail closed instead of falling through to root's own config directory:
|
||||
// reading root's active-profile and email state for what is actually the
|
||||
// invoking user's invocation is the very confusion this resolution exists
|
||||
// to prevent.
|
||||
if sudoActive() {
|
||||
return "", fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root's config directory", os.Getenv(envSudoUser))
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
if u, err := user.Current(); err == nil && u.HomeDir != "" {
|
||||
return filepath.Join(u.HomeDir, "Library", "Application Support"), nil
|
||||
@@ -277,7 +301,10 @@ func createNewConfig(input ConfigInput) (*Config, error) {
|
||||
config := &Config{
|
||||
// defaults to false only for new (post 0.26) configurations
|
||||
ServerSSHAllowed: util.False(),
|
||||
WgPort: iface.DefaultWgPort,
|
||||
// Remote jobs are an explicit opt-in and default off, including for
|
||||
// legacy configs (a nil value materializes to false at connect time).
|
||||
RemoteJobsAllowed: util.False(),
|
||||
WgPort: iface.DefaultWgPort,
|
||||
}
|
||||
|
||||
if _, err := config.apply(input); err != nil {
|
||||
@@ -507,6 +534,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) {
|
||||
if *input.RemoteJobsAllowed {
|
||||
log.Infof("enabling remote jobs")
|
||||
} else {
|
||||
log.Infof("disabling remote jobs")
|
||||
}
|
||||
config.RemoteJobsAllowed = input.RemoteJobsAllowed
|
||||
updated = true
|
||||
} else if config.RemoteJobsAllowed == nil {
|
||||
// Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config
|
||||
// with no value defaults to disabled rather than being turned on.
|
||||
config.RemoteJobsAllowed = util.False()
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) {
|
||||
if *input.EnableSSHRoot {
|
||||
log.Infof("enabling SSH root login")
|
||||
@@ -716,6 +758,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
// for the key, so per-field rejection of user writes still applies).
|
||||
func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
config.policy = policy
|
||||
|
||||
// DebugBundleUploadURL is a runtime-only override re-derived from MDM on
|
||||
// every apply. Resolve it unconditionally (before the IsEmpty early return)
|
||||
// so a policy that drops the key, becomes empty, or carries an invalid
|
||||
// value can never leave a previously-enforced upload target active on a
|
||||
// reused Config instance.
|
||||
config.DebugBundleUploadURL = mdmDebugBundleUploadURL(policy)
|
||||
|
||||
if policy.IsEmpty() {
|
||||
return
|
||||
}
|
||||
@@ -763,6 +813,7 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
}
|
||||
|
||||
applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
|
||||
applyBool(mdm.KeyRemoteJobsAllowed, func(v bool) { bv := v; config.RemoteJobsAllowed = &bv })
|
||||
applyBool(mdm.KeyAllowServerVNC, func(v bool) { bv := v; config.ServerVNCAllowed = &bv })
|
||||
applyBool(mdm.KeyDisableVNCApproval, func(v bool) { bv := v; config.DisableVNCApproval = &bv })
|
||||
applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
|
||||
@@ -798,6 +849,52 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
|
||||
config.LazyConnection = state
|
||||
logApplied(mdm.KeyLazyConnection, state)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ValidateBundleUploadURL sanity-checks a debug-bundle upload URL. An empty
|
||||
// value is accepted — the executor falls back to the default upload service. A
|
||||
// non-empty value must be a well-formed https URL with a host; a malformed
|
||||
// value or a plaintext scheme is rejected. It deliberately does not constrain
|
||||
// which host may receive the bundle. This is the single source of truth for the
|
||||
// rule, shared by the remote-job executor (client/internal) and the MDM policy
|
||||
// override below so the two validation paths cannot drift.
|
||||
func ValidateBundleUploadURL(raw string) error {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse upload URL: %w", err)
|
||||
}
|
||||
// Hostname(), not Host: an authority like ":443" is non-empty but has no
|
||||
// host, and would fail the actual upload.
|
||||
if parsed.Scheme != "https" || parsed.Hostname() == "" {
|
||||
return fmt.Errorf("upload URL must be an https URL with a host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mdmDebugBundleUploadURL resolves the MDM-enforced debug-bundle upload URL
|
||||
// override from the policy, returning the empty string when the policy does
|
||||
// not carry a valid KeyBundleUploadURL. An absent or invalid value fails
|
||||
// closed to "" so it falls back to the management-supplied or default upload
|
||||
// target rather than a previously-enforced one. The URL is never logged: it
|
||||
// can embed credentials or signed query tokens (KeyBundleUploadURL is in
|
||||
// mdm.SecretKeys).
|
||||
func mdmDebugBundleUploadURL(policy *mdm.Policy) string {
|
||||
v, ok := policy.GetString(mdm.KeyBundleUploadURL)
|
||||
if !ok || v == "" {
|
||||
return ""
|
||||
}
|
||||
// Must be a well-formed https URL with a host, matching the client's
|
||||
// remote-job upload-URL validation (shared validator, single source of truth).
|
||||
if err := ValidateBundleUploadURL(v); err != nil {
|
||||
log.Warnf("MDM debug bundle upload URL is invalid (must be an https URL with a host); ignoring the override")
|
||||
return ""
|
||||
}
|
||||
log.Infof("MDM override %s = ********** (secret)", mdm.KeyBundleUploadURL)
|
||||
return v
|
||||
}
|
||||
|
||||
// parseURL parses and validates the URL for the named service. The URL
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -271,6 +272,83 @@ func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigRemoteJobsAllowed(t *testing.T) {
|
||||
// Unlike SSH (which defaults on for legacy configs), remote jobs are an
|
||||
// explicit opt-in: a pre-existing config with no value materializes to off.
|
||||
t.Run("legacy config defaults off", func(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600))
|
||||
|
||||
config, err := UpdateConfig(ConfigInput{ConfigPath: configPath})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config.RemoteJobsAllowed, "RemoteJobsAllowed should be materialized")
|
||||
assert.False(t, *config.RemoteJobsAllowed, "remote jobs must default off")
|
||||
})
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
input *bool
|
||||
want bool
|
||||
}{
|
||||
{"enable", util.True(), true},
|
||||
{"disable", util.False(), false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600))
|
||||
|
||||
config, err := UpdateConfig(ConfigInput{ConfigPath: configPath, RemoteJobsAllowed: tt.input})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config.RemoteJobsAllowed)
|
||||
assert.Equal(t, tt.want, *config.RemoteJobsAllowed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMDMPolicyRemoteJobs(t *testing.T) {
|
||||
t.Run("enables remote jobs and sets the upload URL override", func(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyRemoteJobsAllowed: true,
|
||||
mdm.KeyBundleUploadURL: "https://upload.example.com",
|
||||
}))
|
||||
require.NotNil(t, cfg.RemoteJobsAllowed)
|
||||
assert.True(t, *cfg.RemoteJobsAllowed, "MDM allowRemoteJobs must enable the flag")
|
||||
assert.Equal(t, "https://upload.example.com", cfg.DebugBundleUploadURL, "MDM upload URL override must be applied")
|
||||
})
|
||||
|
||||
t.Run("a non-https upload URL is rejected", func(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyBundleUploadURL: "http://insecure.example.com",
|
||||
}))
|
||||
assert.Empty(t, cfg.DebugBundleUploadURL, "a non-https upload URL must be skipped")
|
||||
})
|
||||
|
||||
t.Run("dropping the key clears a previously-applied override", func(t *testing.T) {
|
||||
cfg := &Config{DebugBundleUploadURL: "https://old.example.com"}
|
||||
// A replacement policy that no longer carries the key must not leave
|
||||
// the old upload target directing bundles.
|
||||
cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyRemoteJobsAllowed: true}))
|
||||
assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared")
|
||||
})
|
||||
|
||||
t.Run("an empty replacement policy clears a previously-applied override", func(t *testing.T) {
|
||||
cfg := &Config{DebugBundleUploadURL: "https://old.example.com"}
|
||||
// A policy that becomes empty entirely hits the IsEmpty early return;
|
||||
// the override must still be cleared rather than surviving on the
|
||||
// reused Config instance.
|
||||
cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{}))
|
||||
assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared when the policy empties")
|
||||
})
|
||||
|
||||
t.Run("an invalid upload URL clears a previously-applied override (fail closed)", func(t *testing.T) {
|
||||
cfg := &Config{DebugBundleUploadURL: "https://old.example.com"}
|
||||
cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyBundleUploadURL: "not-a-url"}))
|
||||
assert.Empty(t, cfg.DebugBundleUploadURL, "an invalid override must fail closed, not keep the stale target")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateOldManagementURL(t *testing.T) {
|
||||
origProber := newMgmProber
|
||||
newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const envSudoUser = "SUDO_USER"
|
||||
|
||||
var (
|
||||
geteuid = os.Geteuid
|
||||
lookupUser = user.Lookup
|
||||
)
|
||||
|
||||
// InvokingUser returns the user a CLI invocation acts for. Under sudo that is
|
||||
// the user who ran sudo, not root: privileged flags force commands through
|
||||
// sudo, and resolving profiles as root would silently switch the daemon to
|
||||
// root's (default) profile instead of the invoking user's. Privilege decisions
|
||||
// are not made here — those stay on the kernel credentials of the daemon
|
||||
// connection, which SUDO_USER (a plain environment variable) can never
|
||||
// influence; a forged value only selects a profile root could select anyway.
|
||||
func InvokingUser() (*user.User, error) {
|
||||
if u, ok := sudoInvokingUser(); ok {
|
||||
return u, nil
|
||||
}
|
||||
// Fail closed instead of falling through to root: every caller feeds this
|
||||
// username into profile-path resolution, so a lookup failure would resolve
|
||||
// (and create) a root-owned profile namespace and switch the daemon onto it
|
||||
// behind the invoking user's back.
|
||||
if sudoActive() {
|
||||
return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
|
||||
}
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// IsPlainRoot reports that the process runs as root with no usable sudo
|
||||
// context: there is no invoking user to act for, so per-user resolution falls
|
||||
// back to root's own (empty) state. Callers use it to refuse ambiguous
|
||||
// operations instead of silently acting on the wrong profile.
|
||||
func IsPlainRoot() bool {
|
||||
if geteuid() != 0 {
|
||||
return false
|
||||
}
|
||||
_, ok := sudoInvokingUser()
|
||||
return !ok
|
||||
}
|
||||
|
||||
// MirrorIsAuthoritative reports whether the invoking user's local
|
||||
// active-profile mirror can be trusted as the profile selector. It cannot under
|
||||
// sudo (writes to it are skipped, so it goes stale) or as plain root (there is
|
||||
// no invoking user, so it falls back to root's own default). Callers use it to
|
||||
// decide whether to read the profile from the mirror or from the daemon.
|
||||
func MirrorIsAuthoritative() bool {
|
||||
return !sudoActive() && !IsPlainRoot()
|
||||
}
|
||||
|
||||
// sudoInvokingUser resolves SUDO_USER when the process runs as root under
|
||||
// sudo. Returns false whenever the sudo context is absent or unusable, in
|
||||
// which case callers fall back to the process user.
|
||||
func sudoInvokingUser() (*user.User, bool) {
|
||||
if !sudoActive() {
|
||||
return nil, false
|
||||
}
|
||||
name := os.Getenv(envSudoUser)
|
||||
u, err := lookupUser(name)
|
||||
if err != nil {
|
||||
log.Warnf("sudo invoking user %q lookup: %v", name, err)
|
||||
return nil, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// sudoActive reports a sudo context from the environment alone: write-skip
|
||||
// decisions key off it so a transient user lookup failure can never flip a
|
||||
// run from read-only to writing root-owned files into the user's directory.
|
||||
func sudoActive() bool {
|
||||
if geteuid() != 0 {
|
||||
return false
|
||||
}
|
||||
name := os.Getenv(envSudoUser)
|
||||
return name != "" && name != "root"
|
||||
}
|
||||
|
||||
// userBaseConfigDir mirrors os.UserConfigDir for a user other than the process
|
||||
// owner. Environment overrides (XDG_CONFIG_HOME) cannot be honoured here: under
|
||||
// sudo the environment is root's, not the invoking user's.
|
||||
func userBaseConfigDir(u *user.User) (string, error) {
|
||||
if u.HomeDir == "" {
|
||||
return "", fmt.Errorf("user %s has no home directory", u.Username)
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
return filepath.Join(u.HomeDir, "Library", "Application Support"), nil
|
||||
}
|
||||
return filepath.Join(u.HomeDir, ".config"), nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
|
||||
t.Setenv(envSudoUser, "")
|
||||
|
||||
got, err := InvokingUser()
|
||||
require.NoError(t, err)
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Username, got.Username)
|
||||
}
|
||||
|
||||
func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
|
||||
t.Setenv(envSudoUser, "")
|
||||
_, ok := sudoInvokingUser()
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestSudoInvokingUserIgnoresRoot(t *testing.T) {
|
||||
t.Setenv(envSudoUser, "root")
|
||||
origEuid := geteuid
|
||||
geteuid = func() int { return 0 }
|
||||
t.Cleanup(func() { geteuid = origEuid })
|
||||
|
||||
_, ok := sudoInvokingUser()
|
||||
assert.False(t, ok, "sudo from a root shell must not redirect anything")
|
||||
assert.False(t, sudoActive())
|
||||
assert.True(t, IsPlainRoot())
|
||||
}
|
||||
|
||||
func TestSudoInvokingUserResolvesInvokingUser(t *testing.T) {
|
||||
fakeSudo(t, filepath.Join("/home", "misha"))
|
||||
|
||||
u, ok := sudoInvokingUser()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "misha", u.Username)
|
||||
|
||||
got, err := InvokingUser()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "misha", got.Username)
|
||||
|
||||
assert.False(t, IsPlainRoot())
|
||||
}
|
||||
|
||||
func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
|
||||
fakeSudo(t, filepath.Join("/home", "misha"))
|
||||
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
|
||||
|
||||
got, err := InvokingUser()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got, "must not resolve to the root process user")
|
||||
}
|
||||
|
||||
func TestProfileFilePathFailsClosedWhenSudoLookupFails(t *testing.T) {
|
||||
profilesRoot := t.TempDir()
|
||||
fakeSudo(t, filepath.Join("/home", "misha"))
|
||||
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
|
||||
|
||||
origDir := DefaultConfigPathDir
|
||||
DefaultConfigPathDir = profilesRoot
|
||||
t.Cleanup(func() { DefaultConfigPathDir = origDir })
|
||||
|
||||
p := &Profile{ID: "0123456789abcdef0123456789abcdef"}
|
||||
_, err := p.FilePath()
|
||||
require.Error(t, err)
|
||||
assertNoEntries(t, profilesRoot)
|
||||
}
|
||||
|
||||
func TestSudoActiveSurvivesLookupFailure(t *testing.T) {
|
||||
fakeSudo(t, filepath.Join("/home", "misha"))
|
||||
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
|
||||
|
||||
_, ok := sudoInvokingUser()
|
||||
assert.False(t, ok)
|
||||
assert.True(t, sudoActive())
|
||||
assert.True(t, IsPlainRoot())
|
||||
}
|
||||
|
||||
func TestGetConfigDirUnderSudoIsReadOnly(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
fakeSudo(t, home)
|
||||
|
||||
base, err := baseConfigDir()
|
||||
require.NoError(t, err)
|
||||
if runtime.GOOS == "darwin" {
|
||||
assert.Equal(t, filepath.Join(home, "Library", "Application Support"), base)
|
||||
} else {
|
||||
assert.Equal(t, filepath.Join(home, ".config"), base)
|
||||
}
|
||||
|
||||
dir, err := getConfigDir()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(base, "netbird"), dir)
|
||||
assert.NoDirExists(t, dir)
|
||||
}
|
||||
|
||||
func TestBaseConfigDirFailsClosedWhenSudoLookupFails(t *testing.T) {
|
||||
fakeSudo(t, filepath.Join("/home", "misha"))
|
||||
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
|
||||
|
||||
_, err := baseConfigDir()
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = getConfigDir()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSwitchProfileSkipsStateWriteUnderSudo(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
fakeSudo(t, home)
|
||||
|
||||
pm := NewProfileManager()
|
||||
require.NoError(t, pm.SwitchProfile(defaultProfileName))
|
||||
assertNoEntries(t, home)
|
||||
}
|
||||
|
||||
func TestSetProfileStateSkipsWriteUnderSudo(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
fakeSudo(t, home)
|
||||
|
||||
pm := NewProfileManager()
|
||||
require.NoError(t, pm.SetProfileState(defaultProfileName, &ProfileState{Email: "misha@example.com"}))
|
||||
assertNoEntries(t, home)
|
||||
}
|
||||
|
||||
func TestRemoveProfileStateSkipsRemoveUnderSudo(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
stateDir := filepath.Join(home, ".config", "netbird")
|
||||
if runtime.GOOS == "darwin" {
|
||||
stateDir = filepath.Join(home, "Library", "Application Support", "netbird")
|
||||
}
|
||||
require.NoError(t, os.MkdirAll(stateDir, 0o700))
|
||||
stateFile := filepath.Join(stateDir, "default.state.json")
|
||||
require.NoError(t, os.WriteFile(stateFile, []byte(`{"email":"misha@example.com"}`), 0o600))
|
||||
|
||||
fakeSudo(t, home)
|
||||
pm := NewProfileManager()
|
||||
require.NoError(t, pm.RemoveProfileState("default"))
|
||||
assert.FileExists(t, stateFile)
|
||||
}
|
||||
|
||||
func TestUserBaseConfigDir(t *testing.T) {
|
||||
u := &user.User{Username: "misha", HomeDir: filepath.Join("/home", "misha")}
|
||||
dir, err := userBaseConfigDir(u)
|
||||
require.NoError(t, err)
|
||||
if runtime.GOOS == "darwin" {
|
||||
assert.Equal(t, filepath.Join(u.HomeDir, "Library", "Application Support"), dir)
|
||||
} else {
|
||||
assert.Equal(t, filepath.Join(u.HomeDir, ".config"), dir)
|
||||
}
|
||||
|
||||
_, err = userBaseConfigDir(&user.User{Username: "nohome"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIsPlainRoot(t *testing.T) {
|
||||
t.Setenv(envSudoUser, "")
|
||||
origEuid := geteuid
|
||||
t.Cleanup(func() { geteuid = origEuid })
|
||||
|
||||
geteuid = func() int { return 1000 }
|
||||
assert.False(t, IsPlainRoot())
|
||||
|
||||
geteuid = func() int { return 0 }
|
||||
assert.True(t, IsPlainRoot())
|
||||
}
|
||||
|
||||
func TestMirrorIsAuthoritative(t *testing.T) {
|
||||
t.Setenv(envSudoUser, "")
|
||||
origEuid := geteuid
|
||||
t.Cleanup(func() { geteuid = origEuid })
|
||||
|
||||
geteuid = func() int { return 1000 }
|
||||
assert.True(t, MirrorIsAuthoritative(), "a normal user's own mirror is authoritative")
|
||||
|
||||
geteuid = func() int { return 0 }
|
||||
assert.False(t, MirrorIsAuthoritative(), "plain root has no authoritative mirror")
|
||||
}
|
||||
|
||||
func TestMirrorIsAuthoritativeFalseUnderSudo(t *testing.T) {
|
||||
fakeSudo(t, filepath.Join("/home", "misha"))
|
||||
assert.False(t, MirrorIsAuthoritative(), "the sudo mirror is frozen, so it is not authoritative")
|
||||
}
|
||||
|
||||
func fakeSudo(t *testing.T, home string) {
|
||||
t.Helper()
|
||||
t.Setenv(envSudoUser, "misha")
|
||||
|
||||
origEuid := geteuid
|
||||
origLookup := lookupUser
|
||||
origOverride := ConfigDirOverride
|
||||
geteuid = func() int { return 0 }
|
||||
lookupUser = func(name string) (*user.User, error) {
|
||||
return &user.User{Username: name, Uid: "1234", Gid: "1234", HomeDir: home}, nil
|
||||
}
|
||||
ConfigDirOverride = ""
|
||||
t.Cleanup(func() {
|
||||
geteuid = origEuid
|
||||
lookupUser = origLookup
|
||||
ConfigDirOverride = origOverride
|
||||
})
|
||||
}
|
||||
|
||||
func assertNoEntries(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path != root {
|
||||
t.Errorf("unexpected entry created under %s: %s", root, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package profilemanager
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -54,7 +53,7 @@ func (p *Profile) FilePath() (string, error) {
|
||||
return "", fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
|
||||
username, err := user.Current()
|
||||
username, err := InvokingUser()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get current user: %w", err)
|
||||
}
|
||||
@@ -130,7 +129,7 @@ func (pm *ProfileManager) getActiveProfileState() ID {
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Warnf("failed to read active profile state: %v", err)
|
||||
} else {
|
||||
} else if !sudoActive() {
|
||||
if err := pm.setActiveProfileState(defaultProfileName); err != nil {
|
||||
log.Warnf("failed to set default profile state: %v", err)
|
||||
}
|
||||
@@ -148,6 +147,13 @@ func (pm *ProfileManager) getActiveProfileState() ID {
|
||||
}
|
||||
|
||||
func (pm *ProfileManager) setActiveProfileState(id ID) error {
|
||||
// The invoking user's state is read-only under sudo — a root-owned file in
|
||||
// the user's directory would break their own runs. The daemon still records
|
||||
// the switch on its side; only the user-local bookkeeping is skipped.
|
||||
if sudoActive() {
|
||||
log.Infof("running under sudo: not persisting active profile %q for user %s", id, os.Getenv(envSudoUser))
|
||||
return nil
|
||||
}
|
||||
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -63,6 +65,15 @@ func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
|
||||
return fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
|
||||
// The invoking user's state is read-only under sudo. The file only carries
|
||||
// the account email for the login hint and display, so skipping the write
|
||||
// costs at most one extra account prompt later — a root-owned file in the
|
||||
// user's directory would cost every later update instead.
|
||||
if sudoActive() {
|
||||
log.Debugf("running under sudo: not persisting profile state for user %s", os.Getenv(envSudoUser))
|
||||
return nil
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil {
|
||||
return fmt.Errorf("write profile state: %w", err)
|
||||
@@ -92,6 +103,11 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
// equivalent to clearing it; the next SSO login recreates it. A missing file
|
||||
// is not an error.
|
||||
func (pm *ProfileManager) RemoveProfileState(profileName string) error {
|
||||
if sudoActive() {
|
||||
log.Debugf("running under sudo: not removing profile state for user %s", os.Getenv(envSudoUser))
|
||||
return nil
|
||||
}
|
||||
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get config directory: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user