mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-23 13:59:09 +02:00
Add profile stamping as active migration
This commit is contained in:
@@ -2,10 +2,12 @@ package profilemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -289,7 +291,7 @@ func TestListProfiles_PrivilegedResolvesUnfiltered(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestListProfiles_UnownedIsPrivilegedOnly(t *testing.T) {
|
||||
func TestListProfiles_UnownedOutsideALegacyDirIsOpen(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
|
||||
unowned, err := sm.AddProfile("unowned", nil)
|
||||
require.NoError(t, err)
|
||||
@@ -297,16 +299,257 @@ func TestListProfiles_UnownedIsPrivilegedOnly(t *testing.T) {
|
||||
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
|
||||
got, err := sm.ListProfiles(alice)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, profileIDs(got), unowned.ID.String(),
|
||||
"an unowned profile is not addressable until it is claimed")
|
||||
assert.Contains(t, profileIDs(got), unowned.ID.String(),
|
||||
"a profile that never had an owner stays usable until someone claims it")
|
||||
assert.Contains(t, profileIDs(got), defaultProfileName,
|
||||
"a fresh install has to be usable before anything is claimed")
|
||||
|
||||
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), unowned.ID.String())
|
||||
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 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()
|
||||
|
||||
Reference in New Issue
Block a user