[client] Profile ownership migration (#7508)

* Implement OwnsProfile on Server

* (WIP) List profiles based on ownership by Identity

* (WIP) Migrate active_profile

* Fix status and list profiles

* Add profile stamping as active migration

* Add one-shot migration

* Only default profile fail open

* Use restricted write for config json

* Fix stale server config after stamp

* Fix OwnsProfile fallback to active profile

* Fix config concurrent reload during OwnsProfile check

* Move known check to inside stamp owner

* Update client/internal/profilemanager/service.go

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Recover from dup active profiles that cannot be resolved with username.

* Add test for already owned profile during migration

* Improve stamping of fields in the config

* Apply suggestion from @cubic-dev-ai[bot]

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Fix codespell and test comment

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Theodor Midtlien
2026-09-17 11:20:12 +02:00
committed by GitHub
co-authored by cubic-dev-ai[bot]
parent 15ed6f8f15
commit 52b16e7a5c
19 changed files with 2050 additions and 270 deletions
+1 -1
View File
@@ -276,7 +276,7 @@ func baseConfigDir() (string, error) {
return os.UserConfigDir()
}
func getConfigDirForUser(username string) (string, error) {
func getConfigDirForUserLegacy(username string) (string, error) {
if ConfigDirOverride != "" {
return ConfigDirOverride, nil
}
+5
View File
@@ -6,4 +6,9 @@ var (
ErrProfileNotFound = errors.New("profile not found")
ErrProfileAlreadyExists = errors.New("profile already exists")
ErrNoActiveProfile = errors.New("no active profile set")
// ErrAmbiguousActiveProfile is returned when the active profile state names
// an ID that several profiles hold and does not say whose directory the
// active one sits in.
ErrAmbiguousActiveProfile = errors.New("active profile is ambiguous")
)
+227
View File
@@ -0,0 +1,227 @@
package profilemanager
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/getent"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// MigrateLegacyProfiles prepares the per-username layout for a daemon that
// addresses profiles by ID and owner.
//
// Two things have to be settled before a directory name can stop carrying
// meaning. Every profile needs an ID no other profile holds, since a legacy ID
// is a display name two accounts can each have, and the profiles of the account
// the machine last ran as need their owner recorded, which the active profile
// state is the only lossless record of.
//
// The profiles.v1 directory is the marker and is created only once both are
// done, so a run that fails leaves no marker and is retried on the next start.
// Callers log a failure and carry on rather than refusing to start.
func (s *ServiceManager) MigrateLegacyProfiles() error {
dest := s.profilesDirPath()
if _, err := os.Stat(dest); err == nil {
return nil
}
profiles, err := s.loadAllProfiles()
if err != nil {
return fmt.Errorf("load profiles: %w", err)
}
active, err := s.GetActiveProfileState()
if err != nil {
return fmt.Errorf("active profile state: %w", err)
}
unresolved, err := s.rekeyDuplicateIDs(profiles, active)
if err != nil {
return err
}
if err := s.stampActiveUserDir(profiles, active); err != nil {
return err
}
if unresolved != "" {
return fmt.Errorf("%w: %q is still held by more than one profile, the active profile state does not say which one is active",
ErrAmbiguousActiveProfile, unresolved)
}
if err := os.MkdirAll(dest, 0700); err != nil {
return fmt.Errorf("create shared profile directory: %w", err)
}
log.Infof("profile migration complete, %s now marks it done", dest)
return nil
}
// rekeyDuplicateIDs gives every profile sharing an ID a fresh one, in place.
// The file stays in its directory, only its name changes, so nothing that holds
// a path to a sibling file is disturbed.
//
// It returns the one ID it could not settle, empty when it settled them all.
func (s *ServiceManager) rekeyDuplicateIDs(profiles []Profile, active *ActiveProfileState) (ID, error) {
groups := make(map[ID][]*Profile, len(profiles))
for i := range profiles {
p := &profiles[i]
if p.ID == defaultProfileName {
continue
}
groups[p.ID] = append(groups[p.ID], p)
}
var unresolved ID
activeDir := sanitizeProfileName(active.Username)
for id, group := range groups {
if len(group) < 2 {
continue
}
// Renaming the active profile without knowing which of the namesakes it
// is would leave the active state pointing at nothing. The recorded
// username is the only thing that tells them apart, so without it the
// safer move is to leave the group alone and keep resolving it the old
// way.
if id == active.ID && activeDir == "" {
log.Warnf("leaving %d profiles named %q as they are, the active profile state does not say which one is active", len(group), id)
unresolved = id
continue
}
for _, p := range group {
wasActive := id == active.ID && filepath.Base(filepath.Dir(p.Path)) == activeDir
fresh, err := generateProfileID()
if err != nil {
return "", fmt.Errorf("generate profile ID: %w", err)
}
if err := rekeyProfile(p, fresh); err != nil {
return "", err
}
if wasActive {
active.ID = fresh
if err := s.SetActiveProfileState(active); err != nil {
return "", fmt.Errorf("repoint active profile: %w", err)
}
}
}
}
return unresolved, nil
}
// renameFile is os.Rename, replaced in tests that need a rename to fail.
var renameFile = os.Rename
// movedFile is a completed rename, kept so it can be undone.
type movedFile struct{ at, was string }
// rekeyProfile renames a profile and its state file to a fresh ID.
//
// The state file move first and the profile file last, we try to restore if
// renaming the profile file.
func rekeyProfile(p *Profile, fresh ID) error {
// A legacy profile's display name is its filename, so it has to be in the
// file before the filename stops meaning anything. The loader already
// falls back to the stem, so writing p.Name is a no-op when the file
// carries a name of its own.
if err := writeProfileName(p.Path, p.Name); err != nil {
return fmt.Errorf("record display name of %s: %w", p.ID, err)
}
dir := filepath.Dir(p.Path)
var moved []movedFile
for _, suffix := range []string{stateFileSuffix, prefsFileSuffix} {
src := filepath.Join(dir, p.ID.String()+suffix)
if _, err := os.Stat(src); err != nil {
continue
}
dst := filepath.Join(dir, fresh.String()+suffix)
if err := renameFile(src, dst); err != nil {
undoMoves(moved)
return fmt.Errorf("rekey %s alongside profile %s: %w", filepath.Base(src), p.ID, err)
}
moved = append(moved, movedFile{at: dst, was: src})
}
target := filepath.Join(dir, fresh.String()+".json")
if err := renameFile(p.Path, target); err != nil {
undoMoves(moved)
return fmt.Errorf("rekey %s: %w", p.ID, err)
}
log.Infof("profile %q in %s now has the unique ID %s", p.ID, dir, fresh)
p.ID, p.Path = fresh, target
return nil
}
// undoMoves puts back what a half-finished rekey moved, so the next start finds
// the profile as this one did and can rekey it from scratch.
func undoMoves(moved []movedFile) {
for _, m := range moved {
if err := renameFile(m.at, m.was); err != nil {
log.Errorf("could not move %s back to %s after a failed rekey, it is now orphaned: %v", m.at, m.was, err)
}
}
}
// stampActiveUserDir records the owner of every unowned profile in the
// directory of the account the active profile state names.
//
// That name is the one lossless input the old layout left behind. Resolving it
// forward, from name to uid, avoids reversing a sanitized directory name, which
// no amount of enumeration does reliably.
func (s *ServiceManager) stampActiveUserDir(profiles []Profile, active *ActiveProfileState) error {
if active.Username == "" {
return nil
}
u, err := getent.LookupUser(active.Username)
if err != nil {
return fmt.Errorf("resolve %q: %w", active.Username, err)
}
principal, ok := principalForUser(u)
if !ok {
return fmt.Errorf("account %q has no usable id %q", active.Username, u.Uid)
}
dir := sanitizeProfileName(active.Username)
for i := range profiles {
p := &profiles[i]
if len(p.Owners) > 0 || p.LegacyUserDir != dir {
continue
}
if err := stampPrincipal(p.Path, principal); err != nil {
log.Warnf("leaving %s unowned, its owner could not be recorded: %v", p.Path, err)
continue
}
log.Infof("recorded %s as the owner of %s, the directory it sits in is that account's", principal, p.Path)
}
return nil
}
// principalForUser turns a resolved account into an owner principal. os/user
// reports a numeric id on Unix and a SID on Windows, which is what tells the
// two kinds apart without a build tag.
func principalForUser(u *user.User) (string, bool) {
if uid, err := strconv.ParseUint(u.Uid, 10, 32); err == nil {
return ipcauth.UIDPrincipal(uint32(uid)), true
}
if strings.HasPrefix(u.Uid, "S-") {
return ipcauth.SIDPrincipal(u.Uid), true
}
return "", false
}
@@ -0,0 +1,291 @@
package profilemanager
import (
"errors"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// currentUserPrincipal returns the running account's name and the owner
// principal migration should record for it. Migration resolves a real account
// through the host's user database, so there is nothing to stub.
func currentUserPrincipal(t *testing.T) (string, string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("the windows path records a SID rather than a numeric uid")
}
u, err := user.Current()
require.NoError(t, err)
uid, err := strconv.ParseUint(u.Uid, 10, 32)
require.NoError(t, err)
return u.Username, "uid:" + strconv.FormatUint(uid, 10)
}
func TestMigrate_StampsTheActiveAccountsDirectory(t *testing.T) {
username, principal := currentUserPrincipal(t)
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
dir := sanitizeProfileName(username)
mine := writeLegacyProfile(t, configDir, dir, "work", nil)
theirs := writeLegacyProfile(t, configDir, "someone-else", "theirs", nil)
require.NoError(t, sm.SetActiveProfileState(&ActiveProfileState{ID: "work", Username: username}))
require.NoError(t, sm.MigrateLegacyProfiles())
assert.Equal(t, []string{principal}, readOwners(t, mine))
assert.Empty(t, readOwners(t, theirs),
"only the account the active state names has a directory we can attribute")
assert.DirExists(t, filepath.Join(configDir, DefaultProfilePathDir),
"the marker is written once both halves are done")
})
}
func TestMigrate_IsIdempotentAndSkipsOnceMarked(t *testing.T) {
username, principal := currentUserPrincipal(t)
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, sanitizeProfileName(username), "work", nil)
require.NoError(t, sm.SetActiveProfileState(&ActiveProfileState{ID: "work", Username: username}))
require.NoError(t, sm.MigrateLegacyProfiles())
require.NoError(t, sm.MigrateLegacyProfiles())
assert.Equal(t, []string{principal}, readOwners(t, path))
// A profile appearing after the marker is left to the per-caller claim.
late := writeLegacyProfile(t, configDir, sanitizeProfileName(username), "late", nil)
require.NoError(t, sm.MigrateLegacyProfiles())
assert.Empty(t, readOwners(t, late))
})
}
func TestMigrate_RekeysDuplicateIDsAndKeepsTheActiveOne(t *testing.T) {
username, principal := currentUserPrincipal(t)
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
dir := sanitizeProfileName(username)
writeLegacyProfile(t, configDir, dir, "work", nil)
writeLegacyProfile(t, configDir, "someone-else", "work", nil)
require.NoError(t, os.WriteFile(filepath.Join(configDir, dir, "work"+stateFileSuffix), []byte("{}"), 0600))
require.NoError(t, sm.SetActiveProfileState(&ActiveProfileState{ID: "work", Username: username}))
require.NoError(t, sm.MigrateLegacyProfiles())
profiles, err := sm.loadAllProfiles()
require.NoError(t, err)
ids := map[ID]int{}
for _, p := range profiles {
ids[p.ID]++
if p.ID != defaultProfileName {
assert.Equal(t, "work", p.Name, "the display name outlives the filename it came from")
}
}
for id, n := range ids {
assert.Equal(t, 1, n, "%s is still shared after migration", id)
}
state, err := sm.GetActiveProfileState()
require.NoError(t, err)
assert.NotEqual(t, ID("work"), state.ID, "the active profile follows its new ID")
active, err := sm.ActiveProfilePath(state)
require.NoError(t, err)
assert.Equal(t, dir, filepath.Base(filepath.Dir(active)),
"and still resolves inside the account that was running it")
assert.Equal(t, []string{principal}, readOwners(t, active))
assert.FileExists(t, filepath.Join(configDir, dir, state.ID.String()+stateFileSuffix),
"the state file follows the profile it belongs to")
})
}
func TestMigrate_UnresolvableAccountLeavesNoMarker(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "ghost", "work", nil)
require.NoError(t, sm.SetActiveProfileState(&ActiveProfileState{
ID: "work", Username: "no-such-account-here",
}))
require.Error(t, sm.MigrateLegacyProfiles())
assert.Empty(t, readOwners(t, path))
assert.NoDirExists(t, filepath.Join(configDir, DefaultProfilePathDir),
"an unfinished run leaves no marker, so the next start tries again")
})
}
// failRenamesOf makes every rename of a file with the given suffix fail, and
// returns the function that lets them through again.
func failRenamesOf(t *testing.T, suffix string) func() {
t.Helper()
orig := renameFile
failing := true
renameFile = func(from, to string) error {
if failing && strings.HasSuffix(from, suffix) {
return errors.New("simulated rename failure")
}
return orig(from, to)
}
t.Cleanup(func() { renameFile = orig })
return func() { failing = false }
}
func TestMigrate_AStateFileThatCannotFollowRollsBackTheRekey(t *testing.T) {
username, _ := currentUserPrincipal(t)
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
dir := sanitizeProfileName(username)
mine := writeLegacyProfile(t, configDir, dir, "work", nil)
writeLegacyProfile(t, configDir, "someone-else", "work", nil)
state := filepath.Join(configDir, dir, "work"+stateFileSuffix)
require.NoError(t, os.WriteFile(state, []byte("{}"), 0600))
require.NoError(t, os.WriteFile(filepath.Join(configDir, dir, "work"+prefsFileSuffix), []byte("{}"), 0600))
require.NoError(t, sm.SetActiveProfileState(&ActiveProfileState{ID: "work", Username: username}))
allowRenames := failRenamesOf(t, prefsFileSuffix)
require.Error(t, sm.MigrateLegacyProfiles())
assert.NoDirExists(t, filepath.Join(configDir, DefaultProfilePathDir),
"a rekey that could not finish leaves no marker, so the next start tries again")
assert.FileExists(t, mine, "the profile is back under the ID its state file still carry")
assert.FileExists(t, state, "and so is the state file that had already moved")
// The next start finds the profile exactly as the failed one did, and
// the state file are still beside it once the rekey goes through.
allowRenames()
require.NoError(t, sm.MigrateLegacyProfiles())
profiles, err := sm.loadAllProfiles()
require.NoError(t, err)
ids := map[ID]int{}
var rolledBack *Profile
for i := range profiles {
p := &profiles[i]
ids[p.ID]++
if p.ID != defaultProfileName && filepath.Dir(p.Path) == filepath.Join(configDir, dir) {
rolledBack = p
}
}
for id, n := range ids {
assert.Equal(t, 1, n, "%s is still shared after migration", id)
}
require.NotNil(t, rolledBack)
for _, suffix := range []string{stateFileSuffix, prefsFileSuffix} {
assert.FileExists(t, filepath.Join(configDir, dir, rolledBack.ID.String()+suffix),
"%s follows the profile it belongs to", suffix)
}
})
}
func TestMigrate_NamesakesTheStateCannotTellApartLeaveNoMarker(t *testing.T) {
username, _ := currentUserPrincipal(t)
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
dir := sanitizeProfileName(username)
mine := writeLegacyProfile(t, configDir, dir, "work", nil)
theirs := writeLegacyProfile(t, configDir, "someone-else", "work", nil)
require.NoError(t, sm.SetActiveProfileState(&ActiveProfileState{ID: "work"}))
require.ErrorIs(t, sm.MigrateLegacyProfiles(), ErrAmbiguousActiveProfile)
assert.NoDirExists(t, filepath.Join(configDir, DefaultProfilePathDir),
"the marker would retire the only pass that can still separate them")
assert.FileExists(t, mine, "so both namesakes are left as they are")
assert.FileExists(t, theirs)
profiles, err := sm.loadAllProfiles()
require.NoError(t, err)
assert.Equal(t, 2, countID(profiles, "work"),
"the group keeps the ID, since rekeying it would leave the state pointing at nothing")
// And until they are separated the active profile does not resolve to
// whichever of them happened to sort first.
state, err := sm.GetActiveProfileState()
require.NoError(t, err)
_, err = sm.ActiveProfilePath(state)
require.ErrorIs(t, err, ErrAmbiguousActiveProfile)
// Selecting a profile records the directory that tells them apart, and
// the start after that finishes the job.
require.NoError(t, sm.SetActiveProfileState(&ActiveProfileState{ID: "work", Username: username}))
require.NoError(t, sm.MigrateLegacyProfiles())
assert.DirExists(t, filepath.Join(configDir, DefaultProfilePathDir))
state, err = sm.GetActiveProfileState()
require.NoError(t, err)
active, err := sm.ActiveProfilePath(state)
require.NoError(t, err)
assert.NotEqual(t, ID("work"), state.ID, "the namesakes are separated")
assert.Equal(t, dir, filepath.Base(filepath.Dir(active)),
"and the active one still sits in the account that was running it")
})
}
// countID reports how many of the loaded profiles hold id.
func countID(profiles []Profile, id ID) int {
n := 0
for _, p := range profiles {
if p.ID == id {
n++
}
}
return n
}
func TestMigrate_LeavesAProfileThatAlreadyNamesAnOwnerAlone(t *testing.T) {
username, principal := currentUserPrincipal(t)
// A SID is never what a Unix host stamps, so it says "someone else holds
// this" without depending on the uid the test happens to run as.
const otherOwner = "sid:S-1-5-21-0-0-0-1001"
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
dir := sanitizeProfileName(username)
claimed := writeLegacyProfile(t, configDir, dir, "claimed", map[string]any{
"Owners": []string{otherOwner},
})
unclaimed := writeLegacyProfile(t, configDir, dir, "unclaimed", nil)
require.NoError(t, sm.SetActiveProfileState(&ActiveProfileState{ID: "unclaimed", Username: username}))
require.NoError(t, sm.MigrateLegacyProfiles())
assert.Equal(t, []string{otherOwner}, readOwners(t, claimed),
"the directory a profile sits in does not re-attribute one that already names an owner")
assert.Equal(t, []string{principal}, readOwners(t, unclaimed),
"only the profiles with no owner of their own are attributed")
})
}
func TestMigrate_SkipsAProfileItCannotStamp(t *testing.T) {
username, principal := currentUserPrincipal(t)
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
dir := sanitizeProfileName(username)
good := writeLegacyProfile(t, configDir, dir, "work", nil)
broken := filepath.Join(configDir, dir, "broken.json")
require.NoError(t, os.WriteFile(broken, []byte("null"), 0600))
require.NoError(t, sm.SetActiveProfileState(&ActiveProfileState{ID: "work", Username: username}))
require.NoError(t, sm.MigrateLegacyProfiles())
assert.Equal(t, []string{principal}, readOwners(t, good),
"one profile that cannot be stamped does not hold up the rest")
assert.DirExists(t, filepath.Join(configDir, DefaultProfilePathDir),
"and the marker still lands, since the claim path retries the one left behind")
data, err := os.ReadFile(broken)
require.NoError(t, err)
assert.Equal(t, "null", string(data), "the profile it could not stamp is untouched")
})
}
+5 -2
View File
@@ -11,7 +11,10 @@ import (
"github.com/netbirdio/netbird/util"
)
const prefsFileSuffix = ".prefs.json"
const (
prefsFileSuffix = ".prefs.json"
stateFileSuffix = ".state.json"
)
var prefsMu sync.Mutex
@@ -29,7 +32,7 @@ func (s *ServiceManager) ProfilePrefs(id ID, username string) (*Prefs, error) {
if id == defaultProfileName {
return &Prefs{path: filepath.Join(filepath.Dir(DefaultConfigPath), id.String()+prefsFileSuffix)}, nil
}
configDir, err := s.getConfigDir(username)
configDir, err := s.getConfigDirLegacy(username)
if err != nil {
return nil, fmt.Errorf("get config directory for user %s: %w", username, err)
}
+34 -14
View File
@@ -3,11 +3,14 @@ package profilemanager
import (
"errors"
"os"
"os/user"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
type testPrefsSection struct {
@@ -15,9 +18,20 @@ type testPrefsSection struct {
Dest string `json:"dest"`
}
// currentUsername returns the account the prefs store keys its legacy config
// directory by. Profile ownership itself is carried by an ipcauth.Identity, but
// the on-disk layout is still per-username.
func currentUsername(t *testing.T) string {
t.Helper()
u, err := user.Current()
require.NoError(t, err)
return u.Username
}
func TestProfilePrefs_RoundTrip(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
username := currentUsername(t)
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
@@ -41,8 +55,9 @@ func TestProfilePrefs_RoundTrip(t *testing.T) {
}
func TestProfilePrefs_GetMissingNamespace(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
username := currentUsername(t)
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
@@ -56,8 +71,9 @@ func TestProfilePrefs_GetMissingNamespace(t *testing.T) {
}
func TestProfilePrefs_RemoveNamespace(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
username := currentUsername(t)
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
@@ -82,15 +98,17 @@ func TestProfilePrefs_RemoveNamespace(t *testing.T) {
}
func TestProfilePrefs_RejectsInvalidID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
username := currentUsername(t)
_, err := sm.ProfilePrefs("../escape", username)
assert.Error(t, err)
})
}
func TestProfilePrefs_RejectsEmptyNamespace(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
username := currentUsername(t)
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
@@ -104,7 +122,8 @@ func TestProfilePrefs_RejectsEmptyNamespace(t *testing.T) {
}
func TestProfilePrefs_DefaultProfile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
username := currentUsername(t)
prefs, err := sm.ProfilePrefs(defaultProfileName, username)
require.NoError(t, err)
@@ -117,21 +136,22 @@ func TestProfilePrefs_DefaultProfile(t *testing.T) {
}
func TestRemoveProfile_DeletesPrefsFile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
username := currentUsername(t)
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
prefs, err := sm.ProfilePrefs(created.ID, username)
require.NoError(t, err)
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2}))
configDir, err := sm.getConfigDir(username)
configDir, err := sm.getConfigDirLegacy(username)
require.NoError(t, err)
prefsPath := filepath.Join(configDir, created.ID.String()+prefsFileSuffix)
_, err = os.Stat(prefsPath)
require.NoError(t, err)
require.NoError(t, sm.RemoveProfile(created.ID, username))
require.NoError(t, sm.RemoveProfile(created.ID, userID))
_, err = os.Stat(prefsPath)
assert.True(t, errors.Is(err, os.ErrNotExist), "prefs file should be removed")
})
@@ -32,6 +32,28 @@ type Profile struct {
Path string
IsActive bool
Owners []ipcauth.Principal
// LegacyUserDir is the sanitized username of the per-username directory the
// profile was found in, empty for the profiles.v1 directory and for the
// default profile. It is the only legacy evidence of profile ownership.
LegacyUserDir string
}
// AccessibleBy reports whether a kernel-attested caller may address this
// profile.
func (p *Profile) AccessibleBy(id ipcauth.Identity) bool {
if !id.Known() {
return false
}
if ipcauth.IsPrivilegedCaller(id) {
return true
}
if len(p.Owners) == 0 {
// A profile in a per-username directory belonged to a use, unowned fails
// closed. The account named by the directory reclaims it on their next
// lookup or until claimed by a privileged caller.
return p.LegacyUserDir == "" && p.ID == DefaultProfileName
}
return p.Owners[0].Matches(id)
}
func (p *Profile) FilePath() (string, error) {
@@ -60,7 +82,7 @@ func (p *Profile) FilePath() (string, error) {
return "", fmt.Errorf("failed to get current user: %w", err)
}
configDir, err := getConfigDirForUser(username.Username)
configDir, err := getConfigDirForUserLegacy(username.Username)
if err != nil {
return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err)
}
@@ -27,7 +27,10 @@ func withPatchedGlobals(t *testing.T, configDir string, testFunc func()) {
DefaultConfigPath = filepath.Join(configDir, "default.json")
ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
oldDefaultConfigPath = filepath.Join(configDir, "old_config.json")
ConfigDirOverride = configDir
// A subdirectory, mirroring production: loadAllProfiles only descends into
// directories under DefaultConfigPathDir, so profiles written straight into
// the config root would be invisible to it.
ConfigDirOverride = filepath.Join(configDir, DefaultProfilePathDir)
// Clean up any files in the config dir to ensure isolation
os.RemoveAll(configDir)
os.MkdirAll(configDir, 0755) //nolint: errcheck
+396 -78
View File
@@ -10,11 +10,14 @@ import (
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/getent"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/util"
)
@@ -27,6 +30,8 @@ var (
DefaultConfigPath = ""
ActiveProfileStatePath = ""
DefaultProfilePathDir = "profiles.v1"
ErrorOldDefaultConfigNotFound = errors.New("old default config not found")
)
@@ -54,11 +59,16 @@ type profileMeta struct {
Name string
}
// nolint:unused
type ownerMeta struct {
Owners []string
}
// Config JSON keys on disk.
const (
ownersFieldName = "Owners"
nameFieldName = "Name"
)
func (e *ErrAmbiguousHandle) Error() string {
switch e.Kind {
case AmbiguityKindIDPrefix:
@@ -98,10 +108,21 @@ type ActiveProfileState struct {
// before the ID-based config files. Legacy values were profile names, which
// were also the legacy filename stems, so they still resolve to the correct
// file on disk.
ID ID `json:"name"`
ID ID `json:"name"`
// Username records which per-username directory a pre-migration profile's
// file lives in. It is a hint for reconstructing that path, not a statement
// about who owns the profile: ownership lives in the profile's own JSON, as
// typed principals. Profiles in the shared directory leave it empty, and
// the field goes away once no per-username directory is left.
Username string `json:"username"`
}
// FilePath rebuilds the profile's path from the per-username layout.
//
// Prefer ServiceManager.ActiveProfilePath: this reconstruction only holds for a
// profile that predates the ID-keyed layout, since a profile created after it
// lives in the shared directory instead, under no username at all.
func (a *ActiveProfileState) FilePath() (string, error) {
if a.ID == "" {
return "", fmt.Errorf("active profile ID is empty")
@@ -115,7 +136,7 @@ func (a *ActiveProfileState) FilePath() (string, error) {
return "", fmt.Errorf("invalid profile ID: %q", a.ID)
}
configDir, err := getConfigDirForUser(a.Username)
configDir, err := getConfigDirForUserLegacy(a.Username)
if err != nil {
return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err)
}
@@ -127,6 +148,74 @@ type ServiceManager struct {
profilesDir string // If set, overrides ConfigDirOverride for profile operations
}
// ActiveProfilePath returns the config file of the profile the active-profile
// state points at.
//
// The path is looked up through the loader rather than rebuilt from the
// recorded username, because a profile's directory is no longer a function of
// who owns it: profiles created since the ID-keyed layout share one directory,
// and only pre-migration ones sit under a per-username one. The username
// survives as a tiebreaker for the single case that still needs one, a legacy
// ID being a display name that two users can each hold.
//
// A state that points at a profile with no file yet still yields the path that
// file would have, so a caller reads "not created yet" from a stat rather than
// from an error.
func (s *ServiceManager) ActiveProfilePath(a *ActiveProfileState) (string, error) {
if a == nil || a.ID == "" {
return "", fmt.Errorf("active profile ID is empty")
}
if a.ID == defaultProfileName {
return DefaultConfigPath, nil
}
if !IsValidProfileFilenameStem(a.ID) {
return "", fmt.Errorf("invalid profile ID: %q", a.ID)
}
profiles, err := s.loadAllProfiles()
if err != nil {
return "", fmt.Errorf("load profiles: %w", err)
}
var matches []Profile
for _, p := range profiles {
if p.ID == a.ID {
matches = append(matches, p)
}
}
if len(matches) == 0 {
// Nothing on disk under that ID, so the legacy layout is the only
// guess left for where the file would go.
return a.FilePath()
}
if len(matches) == 1 {
return matches[0].Path, nil
}
// Migration gives every profile an ID no other profile holds, so getting
// here means it has not run yet or did not finish. Until it does, the
// recorded account name is the only thing telling namesakes apart, and
// picking the wrong one would point the daemon at another user's config.
// State written before the field held a directory recorded the raw account
// name, which the old layout sanitized on its way to becoming one.
for _, want := range []string{a.Username, sanitizeProfileName(a.Username)} {
if want == "" {
continue
}
for _, p := range matches {
if filepath.Base(filepath.Dir(p.Path)) == want {
return p.Path, nil
}
}
}
// Nothing left to tell them apart, so this fails rather than guesses.
return "", fmt.Errorf("%w: %d profiles hold the ID %q and the active profile state does not say which account's directory it is in",
ErrAmbiguousActiveProfile, len(matches), a.ID)
}
func NewServiceManager(defaultConfigPath string) *ServiceManager {
if defaultConfigPath != "" {
DefaultConfigPath = defaultConfigPath
@@ -268,10 +357,6 @@ func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error {
return errors.New("invalid active profile state")
}
if a.ID != defaultProfileName && a.Username == "" {
return fmt.Errorf("username must be set for non-default profiles, got: %s", a.ID)
}
if a.ID != defaultProfileName && !IsValidProfileFilenameStem(a.ID) {
return fmt.Errorf("invalid profile ID: %q", a.ID)
}
@@ -302,8 +387,8 @@ func (s *ServiceManager) DefaultProfilePath() string {
// The returned Profile carries the freshly-generated ID so callers can
// show it to the user (and so the gRPC AddProfileResponse can include
// it).
func (s *ServiceManager) AddProfile(displayName string, username string, callerId *ipcauth.Identity) (*Profile, error) {
configDir, err := s.getConfigDir(username)
func (s *ServiceManager) AddProfile(displayName string, callerId *ipcauth.Identity) (*Profile, error) {
configDir, err := s.getConfigDir()
if err != nil {
return nil, fmt.Errorf("failed to get config directory: %w", err)
}
@@ -325,7 +410,7 @@ func (s *ServiceManager) AddProfile(displayName string, username string, callerI
}
cfg.Name = displayName
if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
if err := util.WriteJsonWithRestrictedPermission(context.Background(), profPath, cfg); err != nil {
return nil, fmt.Errorf("failed to write profile config: %w", err)
}
@@ -336,7 +421,7 @@ func (s *ServiceManager) AddProfile(displayName string, username string, callerI
}, nil
}
func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error {
func (s *ServiceManager) RenameProfile(id ID, userID ipcauth.Identity, newName string) error {
displayName, err := sanitizeDisplayName(newName)
if err != nil {
return fmt.Errorf("invalid profile name: %w", err)
@@ -346,7 +431,7 @@ func (s *ServiceManager) RenameProfile(id ID, username string, newName string) e
return fmt.Errorf("invalid profile ID: %q", id)
}
profiles, err := s.loadAllProfiles(username)
profiles, err := s.loadAllProfilesForIdentity(userID)
if err != nil {
return fmt.Errorf("load profiles: %w", err)
}
@@ -362,26 +447,13 @@ func (s *ServiceManager) RenameProfile(id ID, username string, newName string) e
return ErrProfileNotFound
}
data, err := os.ReadFile(target.Path)
if err != nil {
return err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return err
}
cfg.Name = displayName
if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil {
return fmt.Errorf("failed to write profile name: %w", err)
}
return nil
return writeProfileName(target.Path, displayName)
}
// RemoveProfile deletes the profile identified by id. Callers must have
// already resolved any user-supplied handle to a concrete ID via
// ResolveProfile.
func (s *ServiceManager) RemoveProfile(id ID, username string) error {
func (s *ServiceManager) RemoveProfile(id ID, userID ipcauth.Identity) error {
if id == defaultProfileName {
defaultName := readProfileName(DefaultConfigPath)
if defaultName == "" {
@@ -393,7 +465,7 @@ func (s *ServiceManager) RemoveProfile(id ID, username string) error {
return fmt.Errorf("invalid profile ID: %q", id)
}
profiles, err := s.loadAllProfiles(username)
profiles, err := s.loadAllProfilesForIdentity(userID)
if err != nil {
return fmt.Errorf("load profiles: %w", err)
}
@@ -436,8 +508,8 @@ func (s *ServiceManager) RemoveProfile(id ID, username string) error {
// ListProfiles returns every profile for the given user, including the
// default profile, with IsActive flags set.
func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) {
return s.loadAllProfiles(username)
func (s *ServiceManager) ListProfiles(userID ipcauth.Identity) ([]Profile, error) {
return s.loadAllProfilesForIdentity(userID)
}
// GetStatePath returns the path to the state file based on the operating system
@@ -468,22 +540,47 @@ func (s *ServiceManager) GetStatePath() string {
return defaultStatePath
}
configDir, err := s.getConfigDir(activeProf.Username)
configPath, err := s.ActiveProfilePath(activeProf)
if err != nil {
log.Warnf("failed to get config directory for user %s: %v", activeProf.Username, err)
log.Warnf("failed to resolve the active profile's path: %v", err)
return defaultStatePath
}
return filepath.Join(configDir, activeProf.ID.String()+".state.json")
return filepath.Join(filepath.Dir(configPath), activeProf.ID.String()+".state.json")
}
// getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser
func (s *ServiceManager) getConfigDir(username string) (string, error) {
// getConfigDirLegacy returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser
func (s *ServiceManager) getConfigDirLegacy(username string) (string, error) {
if s.profilesDir != "" {
return s.profilesDir, nil
}
return getConfigDirForUser(username)
return getConfigDirForUserLegacy(username)
}
func (s *ServiceManager) getConfigDir() (string, error) {
configDir := s.profilesDirPath()
if _, err := os.Stat(configDir); os.IsNotExist(err) {
if err := os.MkdirAll(configDir, 0700); err != nil {
return "", err
}
}
return configDir, nil
}
// profilesDirPath returns the directory new profiles are written to without
// creating it, so a read path can name it without leaving a directory behind.
func (s *ServiceManager) profilesDirPath() string {
if s.profilesDir != "" {
return s.profilesDir
}
if ConfigDirOverride != "" {
return ConfigDirOverride
}
return filepath.Join(DefaultConfigPathDir, DefaultProfilePathDir)
}
// loadAllProfiles returns every profile visible to the daemon for the
@@ -493,31 +590,219 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) {
// Each Profile is fully populated: ID is the filename stem, Name comes
// from the JSON's "name" field (falling back to the filename stem when absent)
// and Path is built from a basename read off disk.
func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) {
activeID, activeIsDefault := s.activeProfileID()
func (s *ServiceManager) loadAllProfilesForIdentity(userID ipcauth.Identity) ([]Profile, error) {
allProfiles, err := s.loadAllProfiles()
if err != nil {
return nil, err
}
s.claimLegacyProfiles(allProfiles, userID)
accessible := make([]Profile, 0, len(allProfiles))
for _, p := range allProfiles {
if p.AccessibleBy(userID) {
accessible = append(accessible, p)
}
}
return accessible, nil
}
var (
legacyDirMu sync.Mutex
legacyDirCache = map[string]string{}
)
// claimLegacyProfiles stamps the caller on every unowned profile in the
// directory their own user name produced before the ownership model.
//
// Ownership lives in the file now, so the directory name is only a leftover.
// Flattening is a separate step we are doing in the future. Moving it would
// pull the state file out from under an engine that captured its path at
// connect time.
func (s *ServiceManager) claimLegacyProfiles(profiles []Profile, id ipcauth.Identity) {
// A privileged caller reaches every profile already and an internal load
// has no caller, so neither should leave an owner behind.
if ipcauth.IsPrivilegedCaller(id) {
return
}
if !hasUnownedLegacyProfile(profiles) {
return
}
dir, ok := legacyDirForIdentity(id)
if !ok {
return
}
principal := ipcauth.OwnerPrincipalForIdentity(id)
parsed, ok := ipcauth.ParsePrincipal(principal)
if !ok {
log.Warnf("not claiming legacy profiles, %q is not a usable owner", principal)
return
}
for i := range profiles {
p := &profiles[i]
if len(p.Owners) > 0 || p.LegacyUserDir == "" || p.LegacyUserDir != dir {
continue
}
if err := StampOwner(p.Path, id); err != nil {
log.Warnf("could not claim legacy profile %s for %s: %v", p.Path, principal, err)
continue
}
p.Owners = []ipcauth.Principal{parsed}
log.Infof("claimed legacy profile %s for %s, its directory is named after that account", p.Path, principal)
}
}
func hasUnownedLegacyProfile(profiles []Profile) bool {
for i := range profiles {
if profiles[i].LegacyUserDir != "" && len(profiles[i].Owners) == 0 {
return true
}
}
return false
}
// legacyDirForIdentity is a variable so a test can supply an account name
// without depending on the host's user database.
var legacyDirForIdentity = resolveLegacyDir
// resolveLegacyDir returns the per-username directory the old layout would have
// created for a caller, and whether there is one.
//
// Successes are cached for the process, failures are not, so a directory
// service that is briefly unreachable does not lock its users out until the
// daemon restarts.
func resolveLegacyDir(id ipcauth.Identity) (string, bool) {
key := ipcauth.OwnerPrincipalForIdentity(id)
legacyDirMu.Lock()
cached, hit := legacyDirCache[key]
legacyDirMu.Unlock()
if hit {
return cached, cached != ""
}
lookup := strconv.FormatUint(uint64(id.UID), 10)
if id.IsWindows() {
lookup = id.SID
}
u, err := getent.LookupUserID(lookup)
if err != nil {
log.Warnf("cannot resolve %s to an account name, its legacy profiles stay unowned: %v", key, err)
return "", false
}
dir := sanitizeProfileName(u.Username)
legacyDirMu.Lock()
legacyDirCache[key] = dir
legacyDirMu.Unlock()
return dir, dir != ""
}
func (s *ServiceManager) loadAllProfiles() ([]Profile, error) {
_, activeIsDefault := s.activeProfileID()
defaultName := readProfileName(DefaultConfigPath)
if defaultName == "" {
defaultName = defaultProfileName
}
profiles := []Profile{{
ID: defaultProfileName,
Name: defaultName,
Path: DefaultConfigPath,
IsActive: activeIsDefault,
// TODO: determine how to seed default owners
Owners: []ipcauth.Principal{},
}}
configDir, err := s.getConfigDir(username)
if err != nil {
return nil, fmt.Errorf("get config directory: %w", err)
// The default profile is not seeded with an owner: it starts unowned, and
// the first claim stamps it like any other profile. A file that is not
// there yet is unowned rather than unreadable, since the daemon writes it
// on first run and every listing before that would otherwise fail.
var profiles []Profile
defaultOwners, err := readProfileOwners(DefaultConfigPath)
switch {
case err == nil, errors.Is(err, os.ErrNotExist):
profiles = append(profiles, Profile{
ID: defaultProfileName,
Name: defaultName,
Path: DefaultConfigPath,
IsActive: activeIsDefault,
Owners: defaultOwners,
})
default:
// Same rule as a discovered profile whose owners cannot be read: leave
// it out rather than treat it as unowned, and leave it out rather than
// fail, so one unreadable file does not take every other profile with
// it.
log.Warnf("leaving the default profile out of the listing, its owners could not be read: %v", err)
}
// The directory new profiles go to, plus every per-username directory left
// from before the ID-keyed layout. The first is not necessarily under
// DefaultConfigPathDir: a ServiceManager can be pointed at a directory of
// its own, which is what the mobile bindings do.
dirs := []profileDir{{path: s.profilesDirPath()}}
configPathDir, err := os.ReadDir(DefaultConfigPathDir)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read profile directory: %w", err)
}
for _, entry := range configPathDir {
if !entry.IsDir() || entry.Name() == DefaultProfilePathDir {
continue
}
// Every other subdirectory is named after the account that created the
// profiles in it. The dot in profiles.v1 is what keeps them apart,
// since sanitizeProfileName drops dots.
dirs = append(dirs, profileDir{
path: filepath.Join(DefaultConfigPathDir, entry.Name()),
legacyUser: entry.Name(),
})
}
var fileProfiles []Profile
scanned := make(map[string]bool, len(dirs))
for _, dir := range dirs {
// The profiles directory is usually one of the subdirectories above,
// so without this a profile would be listed twice.
if scanned[dir.path] {
continue
}
scanned[dir.path] = true
dirProfiles, err := s.getProfilesFromDirectory(dir)
if err != nil {
return nil, err
}
fileProfiles = append(fileProfiles, dirProfiles...)
}
sort.Slice(fileProfiles, func(i, j int) bool {
if fileProfiles[i].Name != fileProfiles[j].Name {
return fileProfiles[i].Name < fileProfiles[j].Name
}
// Sort tie-break on ID so duplicate names always render in the same order.
return fileProfiles[i].ID < fileProfiles[j].ID
})
profiles = append(profiles, fileProfiles...)
return profiles, nil
}
// profileDir is one directory the loader scans. legacyUser is the sanitized
// username it is named after, empty for the directory profiles go to now.
type profileDir struct {
path string
legacyUser string
}
func (s *ServiceManager) getProfilesFromDirectory(dir profileDir) ([]Profile, error) {
configDir := dir.path
activeID, _ := s.activeProfileID()
entries, err := os.ReadDir(configDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return profiles, nil
return []Profile{}, nil
}
return nil, fmt.Errorf("read profile directory: %w", err)
}
@@ -550,26 +835,19 @@ func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) {
owners, err := readProfileOwners(path)
if err != nil {
return nil, err
log.Warnf("reading profile owner failed for %s: %v", path, err)
continue
}
fileProfiles = append(fileProfiles, Profile{
ID: stem,
Name: name,
Path: path,
IsActive: stem == ID(activeID),
Owners: owners,
ID: stem,
Name: name,
Path: path,
IsActive: stem == ID(activeID),
Owners: owners,
LegacyUserDir: dir.legacyUser,
})
}
sort.Slice(fileProfiles, func(i, j int) bool {
if fileProfiles[i].Name != fileProfiles[j].Name {
return fileProfiles[i].Name < fileProfiles[j].Name
}
// Sort tie-break on ID so duplicate names always render in the same order.
return fileProfiles[i].ID < fileProfiles[j].ID
})
profiles = append(profiles, fileProfiles...)
return profiles, nil
return fileProfiles, nil
}
// readProfileName parses just the "name" field from the profile Json.
@@ -606,27 +884,67 @@ func readProfileOwners(path string) ([]ipcauth.Principal, error) {
principal, ok := ipcauth.ParsePrincipal(meta.Owners[0])
if !ok {
// A malformed entry is ignored rather than trusted.
log.Warnf("ignoring unparseable owner %q in %s", meta.Owners[0], path)
return nil, nil
// An entry that cannot be parsed is not trusted, and it is not an
// absence of ownership either: the profile records an owner that cannot
// be matched against anyone.
return nil, fmt.Errorf("unparseable owner %q in %s", meta.Owners[0], path)
}
return []ipcauth.Principal{principal}, nil
}
// nolint: unused,unusedfunc
// StampOwner records a caller as a profile's owner, replacing whoever is
// recorded now.
func StampOwner(path string, owner ipcauth.Identity) error {
if !owner.Known() {
return fmt.Errorf("cannot stamp owner that is not verified by the kernel")
}
return stampPrincipal(path, ipcauth.OwnerPrincipalForIdentity(owner))
}
// stampPrincipal records an owner principal directly. Migration needs this: it
// resolves an account name rather than a caller, and a name the kernel never
// vouched for must not become an Identity on the way.
func stampPrincipal(path, principal string) error {
return setProfileField(path, ownersFieldName, []string{principal})
}
// writeProfileName sets a profile's display name. Renaming does it on request,
// migration does it to move a name out of a filename that is about to change.
func writeProfileName(path, name string) error {
return setProfileField(path, nameFieldName, name)
}
// setProfileField replaces one top-level key of a profile's JSON and leaves the
// rest of the document as it found it.
func setProfileField(path, field string, value any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
doc := map[string]json.RawMessage{}
if err := json.Unmarshal(data, &doc); err != nil {
return err
}
cfg.Owners = []string{ipcauth.OwnerPrincipalForIdentity(owner)}
if doc == nil {
return fmt.Errorf("profile %s holds no object to set %s on", path, field)
}
if err := util.WriteJson(context.Background(), path, cfg); err != nil {
return fmt.Errorf("failed to write profile owner: %w", err)
raw, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode %s of %s: %w", field, path, err)
}
// Decoding matches keys case-insensitively
for k := range doc {
if k != field && strings.EqualFold(k, field) {
delete(doc, k)
}
}
doc[field] = raw
if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, doc); err != nil {
return fmt.Errorf("write profile %s: %w", path, err)
}
return nil
}
@@ -648,12 +966,12 @@ func (s *ServiceManager) activeProfileID() (ID, bool) {
// precedence is: exact ID match, then unique exact name, then unique ID
// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can
// surface the candidates.
func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, error) {
func (s *ServiceManager) ResolveProfile(handle string, userID ipcauth.Identity) (*Profile, error) {
if handle == "" {
return nil, fmt.Errorf("profile handle is empty")
}
profiles, err := s.loadAllProfiles(username)
profiles, err := s.loadAllProfilesForIdentity(userID)
if err != nil {
return nil, err
}
+568 -36
View File
@@ -2,22 +2,26 @@ package profilemanager
import (
"context"
"encoding/json"
"errors"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/util"
)
// withTestSM wires up patched globals + a clean config dir and returns a
// fully initialized ServiceManager plus the username we are scoped to.
func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
func withTestSM(t *testing.T, fn func(sm *ServiceManager, id ipcauth.Identity)) {
t.Helper()
withTempConfigDir(t, func(configDir string) {
withPatchedGlobals(t, configDir, func() {
@@ -25,17 +29,21 @@ func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
require.NoError(t, err)
sm := &ServiceManager{}
require.NoError(t, sm.CreateDefaultProfile())
fn(sm, u.Username)
uid, err := strconv.ParseUint(u.Uid, 10, 32)
require.NoError(t, err)
userID := ipcauth.Identity{UID: uint32(uid)}
userID = ipcauth.KnownForTest(userID)
fn(sm, userID)
})
})
}
func TestServiceProfile_ExactID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", nil)
require.NoError(t, err)
got, err := sm.ResolveProfile(created.ID.String(), username)
got, err := sm.ResolveProfile(created.ID.String(), userID)
require.NoError(t, err)
assert.Equal(t, created.ID, got.ID)
assert.Equal(t, "work", got.Name)
@@ -43,29 +51,31 @@ func TestServiceProfile_ExactID(t *testing.T) {
}
func TestServiceProfile_IDPrefix(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
prefix := created.ID[:4]
got, err := sm.ResolveProfile(prefix.String(), username)
got, err := sm.ResolveProfile(prefix.String(), userID)
require.NoError(t, err)
assert.Equal(t, created.ID, got.ID)
})
}
func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
// Plant two profiles whose IDs share a known prefix by writing
// the files directly, since generated IDs are random.
configDir, err := sm.getConfigDir(username)
user, err := user.Current()
require.NoError(t, err)
configDir, err := sm.getConfigDirLegacy(user.Username)
require.NoError(t, err)
for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} {
path := filepath.Join(configDir, id+".json")
require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id}))
}
_, err = sm.ResolveProfile("abcd", username)
_, err = sm.ResolveProfile("abcd", userID)
var amb *ErrAmbiguousHandle
require.ErrorAs(t, err, &amb)
assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind)
@@ -74,24 +84,24 @@ func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
}
func TestServiceProfile_ExactNameUnique(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
_, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
got, err := sm.ResolveProfile("work", username)
got, err := sm.ResolveProfile("work", userID)
require.NoError(t, err)
assert.Equal(t, "work", got.Name)
})
}
func TestServiceProfile_AmbiguousName(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
_, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
_, err = sm.AddProfile("work", username, nil)
_, err = sm.AddProfile("work", &userID)
require.NoError(t, err)
_, err = sm.ResolveProfile("work", username)
_, err = sm.ResolveProfile("work", userID)
var amb *ErrAmbiguousHandle
require.ErrorAs(t, err, &amb)
assert.Equal(t, AmbiguityKindName, amb.Kind)
@@ -100,15 +110,15 @@ func TestServiceProfile_AmbiguousName(t *testing.T) {
}
func TestServiceProfile_NotFound(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
_, err := sm.ResolveProfile("nope", username)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
_, err := sm.ResolveProfile("nope", userID)
assert.ErrorIs(t, err, ErrProfileNotFound)
})
}
func TestServiceProfile_DefaultByExactID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
got, err := sm.ResolveProfile(defaultProfileName, username)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
got, err := sm.ResolveProfile(defaultProfileName, userID)
require.NoError(t, err)
assert.Equal(t, defaultProfileName, got.ID.String())
})
@@ -117,13 +127,15 @@ func TestServiceProfile_DefaultByExactID(t *testing.T) {
func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
// Legacy profiles stored as <name>.json with no "name" JSON field
// should still be discoverable by name and removable by name.
withTestSM(t, func(sm *ServiceManager, username string) {
configDir, err := sm.getConfigDir(username)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
user, err := user.Current()
require.NoError(t, err)
configDir, err := sm.getConfigDirLegacy(user.Username)
require.NoError(t, err)
path := filepath.Join(configDir, "legacy.json")
require.NoError(t, util.WriteJson(context.Background(), path, &Config{}))
got, err := sm.ResolveProfile("legacy", username)
got, err := sm.ResolveProfile("legacy", userID)
require.NoError(t, err)
assert.Equal(t, "legacy", got.ID.String())
// Name falls back to the filename stem when JSON omits it.
@@ -132,11 +144,11 @@ func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
}
func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
first, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
first, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
second, err := sm.AddProfile("work", username, nil)
second, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID)
assert.Equal(t, "work", second.Name)
@@ -144,22 +156,22 @@ func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
}
func TestAddProfile_RejectsInvalidNames(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
cases := []string{
"", // empty
"\x00\x01", // only control chars (becomes empty)
strings.Repeat("a", maxProfileNameLen+1), // too long
}
for _, name := range cases {
_, err := sm.AddProfile(name, username, nil)
_, err := sm.AddProfile(name, &userID)
assert.Error(t, err, "expected error for %q", name)
}
})
}
func TestRemoveProfile_RejectsInvalidID(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
err := sm.RemoveProfile("../escape", username)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
err := sm.RemoveProfile("../escape", userID)
assert.Error(t, err)
})
}
@@ -214,17 +226,537 @@ func TestIsValidProfileFilenameStem(t *testing.T) {
}
func TestRemoveProfile_DeletesStateFile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, username string) {
created, err := sm.AddProfile("work", username, nil)
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
configDir, err := sm.getConfigDir(username)
user, err := user.Current()
require.NoError(t, err)
configDir, err := sm.getConfigDirLegacy(user.Username)
require.NoError(t, err)
statePath := filepath.Join(configDir, created.ID.String()+".state.json")
require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600))
require.NoError(t, sm.RemoveProfile(created.ID, username))
require.NoError(t, sm.RemoveProfile(created.ID, userID))
_, err = os.Stat(statePath)
assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed")
})
}
// profileIDs is the set of profile IDs in a listing, for membership assertions
// that do not care about the default profile always being present.
func profileIDs(profiles []Profile) []string {
ids := make([]string, 0, len(profiles))
for _, p := range profiles {
ids = append(ids, p.ID.String())
}
return ids
}
func TestListProfiles_ScopedToOwner(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
// Two synthetic users rather than the current one: this process is its
// own daemon, and IsPrivilegedCaller delegates to a caller sharing an
// unprivileged daemon's identity, so the current user resolves
// unfiltered here.
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
bob := ipcauth.KnownForTest(ipcauth.Identity{UID: 4243})
hers, err := sm.AddProfile("hers", &alice)
require.NoError(t, err)
his, err := sm.AddProfile("his", &bob)
require.NoError(t, err)
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), hers.ID.String())
assert.NotContains(t, profileIDs(got), his.ID.String(),
"another user's profile must not be listed")
})
}
func TestListProfiles_PrivilegedResolvesUnfiltered(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
other := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
mine, err := sm.AddProfile("mine", &userID)
require.NoError(t, err)
theirs, err := sm.AddProfile("theirs", &other)
require.NoError(t, err)
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err := sm.ListProfiles(root)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), mine.ID.String())
assert.Contains(t, profileIDs(got), theirs.ID.String())
assert.Contains(t, profileIDs(got), defaultProfileName)
})
}
func TestListProfiles_OnlyTheDefaultFailsOpenWhenUnowned(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
unowned, err := sm.AddProfile("unowned", nil)
require.NoError(t, err)
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), defaultProfileName,
"a fresh install has to be usable before anything is claimed")
assert.NotContains(t, profileIDs(got), unowned.ID.String(),
"every other profile needs an owner before anyone can address it")
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err = sm.ListProfiles(root)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), unowned.ID.String(),
"root still reaches it, which is how it gets assigned")
nobody, err := sm.ListProfiles(ipcauth.Identity{})
require.NoError(t, err)
assert.Empty(t, profileIDs(nobody), "an unattested caller reaches nothing")
})
}
// withLegacyLayout wires up the globals the way withTestSM does, without the
// identity, since these tests supply their own.
func withLegacyLayout(t *testing.T, fn func(sm *ServiceManager, configDir string)) {
t.Helper()
withTempConfigDir(t, func(configDir string) {
withPatchedGlobals(t, configDir, func() {
sm := &ServiceManager{}
require.NoError(t, sm.CreateDefaultProfile())
fn(sm, configDir)
})
})
}
// writeLegacyProfile drops a profile into a per-username directory, the layout
// every profile used before the ID-keyed one. It writes no name, since a legacy
// profile took its display name from the filename.
func writeLegacyProfile(t *testing.T, configDir, dirName, id string, fields map[string]any) string {
t.Helper()
dir := filepath.Join(configDir, dirName)
require.NoError(t, os.MkdirAll(dir, 0700))
doc := map[string]any{}
for k, v := range fields {
doc[k] = v
}
path := filepath.Join(dir, id+".json")
require.NoError(t, util.WriteJson(context.Background(), path, doc))
return path
}
// stubLegacyDir replaces the account lookup, so the claim path can be exercised
// without an account existing on the machine running the test.
func stubLegacyDir(t *testing.T, dir string) {
t.Helper()
orig := legacyDirForIdentity
legacyDirForIdentity = func(ipcauth.Identity) (string, bool) { return dir, dir != "" }
t.Cleanup(func() { legacyDirForIdentity = orig })
}
func readOwners(t *testing.T, path string) []string {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err)
var meta ownerMeta
require.NoError(t, json.Unmarshal(data, &meta))
return meta.Owners
}
func TestListProfiles_UnownedLegacyProfileIsPrivilegedOnly(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
stubLegacyDir(t, "bob")
bob := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(bob)
require.NoError(t, err)
assert.NotContains(t, profileIDs(got), "work",
"a profile sitting in someone else's directory is not free to take")
assert.Empty(t, readOwners(t, path), "and it is not claimed on the way past")
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err = sm.ListProfiles(root)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), "work",
"root still reaches it, which is how it gets reassigned")
})
}
func TestListProfiles_ClaimsLegacyProfileForItsOwnAccount(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), "work",
"the claim lands before the listing is filtered, so the gap closes in one call")
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
// The claim is on disk now, so it is the owner check and not the
// directory name that keeps the next caller out.
stubLegacyDir(t, "alice")
other := ipcauth.KnownForTest(ipcauth.Identity{UID: 5252})
got, err = sm.ListProfiles(other)
require.NoError(t, err)
assert.NotContains(t, profileIDs(got), "work")
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path),
"a second caller does not overwrite a stamped owner")
})
}
func TestClaimLegacyProfile_LeavesTheProfileWhereItIs(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
for _, suffix := range []string{".state.json", prefsFileSuffix} {
require.NoError(t, os.WriteFile(filepath.Join(configDir, "alice", "work"+suffix), []byte("{}"), 0600))
}
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
claimed := ownedProfile(t, got, "uid:4242")
assert.Equal(t, ID("work"), claimed.ID, "claiming does not re-key the profile")
assert.Equal(t, path, claimed.Path)
assert.Equal(t, "alice", claimed.LegacyUserDir,
"the directory is a leftover now, not something the claim rewrites")
// The engine captures its state file path at connect time and writes
// back to whatever it captured, so nothing here may move that file.
assert.FileExists(t, filepath.Join(configDir, "alice", "work.state.json"))
assert.FileExists(t, filepath.Join(configDir, "alice", "work"+prefsFileSuffix))
assert.NoFileExists(t, filepath.Join(configDir, DefaultProfilePathDir, "work.json"))
})
}
func TestClaimLegacyProfile_NamesakeInAnotherDirectoryIsUntouched(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
writeLegacyProfile(t, configDir, "alice", "work", nil)
bobs := writeLegacyProfile(t, configDir, "bob", "work", nil)
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
claimed := ownedProfile(t, got, "uid:4242")
assert.Equal(t, filepath.Join(configDir, "alice", "work.json"), claimed.Path,
"a legacy ID is a display name two accounts can hold, so the path is what tells them apart")
assert.Empty(t, readOwners(t, bobs), "bob's namesake is not claimed")
})
}
func ownedProfile(t *testing.T, profiles []Profile, principal string) Profile {
t.Helper()
var found []Profile
for _, p := range profiles {
if len(p.Owners) == 1 && p.Owners[0].String() == principal {
found = append(found, p)
}
}
require.Len(t, found, 1, "exactly one profile is owned by %s", principal)
return found[0]
}
func TestClaimLegacyProfile_SkipsOneAlreadyOwnedInTheSameDirectory(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
// One unowned profile keeps the claim running over the directory, so
// the owned one beside it is reached and has to be left alone.
taken := writeLegacyProfile(t, configDir, "alice", "taken", map[string]any{
"Owners": []string{"uid:9999"},
})
free := writeLegacyProfile(t, configDir, "alice", "free", nil)
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Equal(t, []string{"uid:9999"}, readOwners(t, taken),
"a profile that already has an owner is not restamped")
assert.Equal(t, []string{"uid:4242"}, readOwners(t, free))
})
}
func TestRenameProfile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
require.NoError(t, sm.RenameProfile(created.ID, userID, "weekend"))
got, err := sm.ResolveProfile(created.ID.String(), userID)
require.NoError(t, err)
assert.Equal(t, "weekend", got.Name, "the new name is on disk")
assert.Equal(t, created.ID, got.ID, "renaming does not re-key the profile")
assert.Equal(t, created.Path, got.Path)
})
}
func TestRenameProfile_NotTheCallersProfile(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
created, err := sm.AddProfile("work", &userID)
require.NoError(t, err)
stranger := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
require.Error(t, sm.RenameProfile(created.ID, stranger, "weekend"),
"a profile the caller cannot address is not theirs to rename")
got, err := sm.ResolveProfile(created.ID.String(), userID)
require.NoError(t, err)
assert.Equal(t, "work", got.Name)
})
}
func TestResolveProfile_ClaimsOnTheWayThrough(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
stubLegacyDir(t, "alice")
// Resolution is what switching a profile goes through, so the claim has
// to land here and not only when something lists profiles.
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ResolveProfile("work", alice)
require.NoError(t, err)
assert.Equal(t, path, got.Path)
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
})
}
func TestListProfiles_PrivilegedCallerDoesNotClaim(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "root", "work", nil)
stubLegacyDir(t, "root")
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err := sm.ListProfiles(root)
require.NoError(t, err)
assert.Contains(t, profileIDs(got), "work")
assert.Empty(t, readOwners(t, path),
"root reaches every profile anyway, so a listing must not stamp one")
})
}
func TestStampOwner(t *testing.T) {
withLegacyLayout(t, func(_ *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", map[string]any{
"DisableAutoConnect": true,
})
require.NoError(t, StampOwner(path, ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})))
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
var fields map[string]any
data, err := os.ReadFile(path)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(data, &fields))
assert.Equal(t, true, fields["DisableAutoConnect"],
"the rest of the config survives the stamp")
require.NoError(t, StampOwner(path, ipcauth.KnownForTest(ipcauth.Identity{UID: 5252})))
assert.Equal(t, []string{"uid:5252"}, readOwners(t, path),
"stamping replaces whoever is recorded, the caller decides whether to")
})
}
func TestClaimLegacyProfile_LeavesAnOwnerItCannotParse(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
// A principal kind this build does not know, which is what a client
// newer than this one leaves behind after a downgrade. The loader
// drops such a profile rather than reporting it as unowned, which is
// what keeps the claim from treating it as free to take.
path := writeLegacyProfile(t, configDir, "alice", "work", map[string]any{
"Owners": []string{"group:devs"},
})
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.NotContains(t, profileIDs(got), "work")
assert.Equal(t, []string{"group:devs"}, readOwners(t, path),
"the profile is not taken over, it waits for an explicit claim")
})
}
func TestResolveLegacyDir_SanitizesTheSameStringTheOldLayoutDid(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the windows path resolves a SID rather than a numeric uid")
}
u, err := user.Current()
require.NoError(t, err)
uid, err := strconv.ParseUint(u.Uid, 10, 32)
require.NoError(t, err)
got, ok := resolveLegacyDir(ipcauth.KnownForTest(ipcauth.Identity{UID: uint32(uid)}))
require.True(t, ok)
assert.Equal(t, sanitizeProfileName(u.Username), got,
"the directory has to come from the account name, sanitized the way the old layout did")
}
func TestListProfiles_UnreadableOwnersAreSkipped(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
configDir, err := sm.getConfigDir()
require.NoError(t, err)
require.NoError(t, os.MkdirAll(configDir, 0700))
// An owner entry that parses as JSON but names no principal: the
// profile records an owner, so it is not unowned, but nothing can match
// it.
const tampered = "abcd1111aaaa"
path := filepath.Join(configDir, tampered+".json")
require.NoError(t, os.WriteFile(path, []byte(`{"Name":"tampered","Owners":["garbage"]}`), 0600))
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
got, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.NotContains(t, profileIDs(got), tampered,
"a profile whose owners cannot be read must not fall back to unowned")
// Not even a privileged caller: the loader drops the profile before
// ownership is ever consulted, so corrupting the owner list hides the
// profile rather than unlocking it.
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
got, err = sm.ListProfiles(root)
require.NoError(t, err)
assert.NotContains(t, profileIDs(got), tampered)
})
}
func TestListProfiles_UnidentifiedCallerGetsNothing(t *testing.T) {
withTestSM(t, func(sm *ServiceManager, userID ipcauth.Identity) {
_, err := sm.AddProfile("mine", &userID)
require.NoError(t, err)
_, err = sm.AddProfile("unowned", nil)
require.NoError(t, err)
// The zero Identity carries uid 0, so an unidentified caller must be
// refused before privilege is ever considered.
got, err := sm.ListProfiles(ipcauth.Identity{})
require.NoError(t, err)
assert.Empty(t, got)
})
}
func TestActiveProfilePath_RefusesToGuessBetweenNamesakes(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
writeLegacyProfile(t, configDir, "alice", "work", nil)
writeLegacyProfile(t, configDir, "bob", "work", nil)
_, err := sm.ActiveProfilePath(&ActiveProfileState{ID: "work"})
require.ErrorIs(t, err, ErrAmbiguousActiveProfile,
"running one account's config under another account's name is worse than not running")
path, err := sm.ActiveProfilePath(&ActiveProfileState{ID: "work", Username: "bob"})
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "bob", "work.json"), path,
"the recorded directory is what tells the namesakes apart")
})
}
func TestListProfiles_ClaimKeepsFieldsThisVersionDoesNotModel(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
// What a client newer than this one leaves behind: a key Config has no
// field for, next to one it does.
newer := map[string]any{"Enabled": true, "Hosts": []any{"a", "b"}}
path := writeLegacyProfile(t, configDir, "alice", "work", map[string]any{
"MTU": 1280,
"SomethingNewer": newer,
})
stubLegacyDir(t, "alice")
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
_, err := sm.ListProfiles(alice)
require.NoError(t, err)
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path), "the claim still lands")
data, err := os.ReadFile(path)
require.NoError(t, err)
var doc map[string]any
require.NoError(t, json.Unmarshal(data, &doc))
assert.Equal(t, newer, doc["SomethingNewer"],
"a listing must not drop the settings of a client that models more than this one")
assert.Equal(t, float64(1280), doc["MTU"], "and leaves the ones it does model alone")
assert.NotContains(t, doc, "PrivateKey",
"nor write out the rest of Config just because it has fields for it")
})
}
func TestSetProfileField_ReplacesAKeySpelledInAnotherCase(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := writeLegacyProfile(t, configDir, "alice", "work", map[string]any{
"owners": []any{"uid:1"},
})
require.NoError(t, stampPrincipal(path, "uid:4242"))
data, err := os.ReadFile(path)
require.NoError(t, err)
var doc map[string]any
require.NoError(t, json.Unmarshal(data, &doc))
assert.NotContains(t, doc, "owners",
"two spellings of one field would leave the reader to pick")
assert.Equal(t, []any{"uid:4242"}, doc["Owners"])
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
})
}
func TestSetProfileField_RefusesADocumentThatIsNotAnObject(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
path := filepath.Join(configDir, "alice", "work.json")
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700))
require.NoError(t, os.WriteFile(path, []byte("null"), 0600))
require.Error(t, stampPrincipal(path, "uid:4242"),
"a profile that is not an object is not one an owner can be set on")
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, "null", string(data), "and it is left as it was found")
})
}
func TestSetProfileField_KeepsKeysItWasNotAskedToWrite(t *testing.T) {
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
// Keys Config has no field for, in every shape a newer client could
// leave one behind.
unknown := map[string]any{
"String": "keep me",
"Number": float64(7),
"Bool": true,
"Null": nil,
"List": []any{"a", float64(2), false},
"Object": map[string]any{"Nested": map[string]any{"Deep": []any{float64(1)}}},
}
fields := map[string]any{"MTU": 1280}
for k, v := range unknown {
fields[k] = v
}
path := writeLegacyProfile(t, configDir, "alice", "work", fields)
require.NoError(t, setProfileField(path, ownersFieldName, []string{"uid:4242"}))
require.NoError(t, setProfileField(path, nameFieldName, "Work"))
data, err := os.ReadFile(path)
require.NoError(t, err)
var doc map[string]any
require.NoError(t, json.Unmarshal(data, &doc))
for k, want := range unknown {
assert.Equal(t, want, doc[k],
"%s is not a key this version models, so it is not this version's to drop", k)
}
assert.Equal(t, float64(1280), doc["MTU"], "a key it does model is left where it was too")
assert.Equal(t, []any{"uid:4242"}, doc["Owners"], "and the fields it was asked for are written")
assert.Equal(t, "Work", doc["Name"])
assert.Len(t, doc, len(unknown)+3, "with nothing else added")
})
}