Cleaup profile parsing

This commit is contained in:
Theodor S. Midtlien
2026-09-18 12:22:04 +02:00
parent cb9d2ee3bb
commit 29be224cdf
2 changed files with 228 additions and 113 deletions
+197 -112
View File
@@ -68,10 +68,7 @@ const (
// profileMeta is the minimal slice of a profile JSON we need, so we avoid // profileMeta is the minimal slice of a profile JSON we need, so we avoid
// reading all fields // reading all fields
type profileMeta struct { type profileMeta struct {
Name string Name string
}
type ownerMeta struct {
Owners []string Owners []string
} }
@@ -451,14 +448,14 @@ func (s *ServiceManager) RenameProfile(id ID, newName string) error {
return writeProfileName(target.Path, displayName) return writeProfileName(target.Path, displayName)
} }
// RemoveProfile deletes the profile identified by id. Callers must have // RemoveProfile deletes the profile identified by id. Callers must have already
// already resolved any user-supplied handle to a concrete ID via // turned any user-supplied handle into a concrete ID, which is what the
// ResolveProfile. // authorization gate does before a handler runs.
func (s *ServiceManager) RemoveProfile(id ID) error { func (s *ServiceManager) RemoveProfile(id ID) error {
if id == defaultProfileName { if id == defaultProfileName {
defaultName := readProfileName(DefaultConfigPath) defaultName := defaultProfileName
if defaultName == "" { if defaultProfile, err := parseDefaultProfile(); err == nil {
defaultName = defaultProfileName defaultName = defaultProfile.Name
} }
return fmt.Errorf("cannot remove default profile with name: %s", defaultName) return fmt.Errorf("cannot remove default profile with name: %s", defaultName)
} }
@@ -772,67 +769,23 @@ func resolveLegacyDir(id ipcauth.Identity) (string, bool) {
func (s *ServiceManager) loadAllProfiles() ([]Profile, error) { func (s *ServiceManager) loadAllProfiles() ([]Profile, error) {
_, activeIsDefault := s.activeProfileID() _, activeIsDefault := s.activeProfileID()
defaultName := readProfileName(DefaultConfigPath)
if defaultName == "" {
defaultName = defaultProfileName
}
// 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 var profiles []Profile
defaultOwners, err := readProfileOwners(DefaultConfigPath) defaultProfile, err := parseDefaultProfile()
switch { if err != nil {
case err == nil, errors.Is(err, os.ErrNotExist): log.Warnf("leaving the default profile out of the listing: %v", err)
profiles = append(profiles, Profile{ } else {
ID: defaultProfileName, defaultProfile.IsActive = activeIsDefault
Name: defaultName, profiles = append(profiles, defaultProfile)
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 dirs, err := s.profileDirs()
// from before the ID-keyed layout. The first is not necessarily under if err != nil {
// DefaultConfigPathDir: a ServiceManager can be pointed at a directory of return nil, err
// 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 var fileProfiles []Profile
scanned := make(map[string]bool, len(dirs))
for _, dir := range 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) dirProfiles, err := s.getProfilesFromDirectory(dir)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -858,6 +811,48 @@ type profileDir struct {
legacyUser string legacyUser string
} }
// profileDirs lists the directories a profile can live in: the one new profiles
// go to, plus every per-username directory left from before the ID-keyed
// layout. The first is not necessarily under DefaultConfigPathDir, since a
// ServiceManager can be pointed at a directory of its own, which is what the
// mobile bindings do.
//
// The default profile is not in any of them: it sits at DefaultConfigPath.
func (s *ServiceManager) profileDirs() ([]profileDir, error) {
dirs := []profileDir{{path: s.profilesDirPath()}}
entries, err := os.ReadDir(DefaultConfigPathDir)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read profile directory: %w", err)
}
for _, entry := range entries {
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(),
})
}
// The profiles directory is usually one of the subdirectories above, so
// without this a profile would be read twice.
// Guard for mobile subdirectories.
seen := make(map[string]bool, len(dirs))
unique := dirs[:0]
for _, dir := range dirs {
if seen[dir.path] {
continue
}
seen[dir.path] = true
unique = append(unique, dir)
}
return unique, nil
}
func (s *ServiceManager) getProfilesFromDirectory(dir profileDir) ([]Profile, error) { func (s *ServiceManager) getProfilesFromDirectory(dir profileDir) ([]Profile, error) {
configDir := dir.path configDir := dir.path
activeID, _ := s.activeProfileID() activeID, _ := s.activeProfileID()
@@ -889,67 +884,103 @@ func (s *ServiceManager) getProfilesFromDirectory(dir profileDir) ([]Profile, er
if !IsValidProfileFilenameStem(ID(stem)) { if !IsValidProfileFilenameStem(ID(stem)) {
continue continue
} }
path := filepath.Join(configDir, base) profile, err := parseProfileFile(filepath.Join(configDir, base), dir.legacyUser)
name := readProfileName(path)
if name == "" {
name = stem.String()
}
owners, err := readProfileOwners(path)
if err != nil { if err != nil {
log.Warnf("reading profile owner failed for %s: %v", path, err) log.Warnf("leaving profile %s out of the listing: %v", base, err)
continue continue
} }
fileProfiles = append(fileProfiles, Profile{ profile.IsActive = profile.ID == ID(activeID)
ID: stem,
Name: name, fileProfiles = append(fileProfiles, profile)
Path: path,
IsActive: stem == ID(activeID),
Owners: owners,
LegacyUserDir: dir.legacyUser,
})
} }
return fileProfiles, nil return fileProfiles, nil
} }
// readProfileName parses just the "name" field from the profile Json. // parseProfile turns one file on disk into a Profile. It is the only place that
func readProfileName(path string) string { // conversion happens, so a listing and a single lookup cannot drift on what a
// profile file means, and it reads the file once rather than once per field.
//
// The ID is given rather than taken from the filename. Every profile but one is
// named after its ID; the default profile's file is named by the platform, and
// the mobile bindings call it netbird.cfg.
//
// IsActive is left to the caller: it depends on which profile the daemon is on
// rather than on the file, and a caller listing many profiles already knows it.
func parseProfile(path string, id ID, legacyUser string) (Profile, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return "" return Profile{}, err
} }
var meta profileMeta var meta profileMeta
if err := json.Unmarshal(data, &meta); err != nil { if err := json.Unmarshal(data, &meta); err != nil {
return "" return Profile{}, fmt.Errorf("parse profile %s: %w", path, err)
} }
return meta.Name
owners, err := parseOwners(meta.Owners)
if err != nil {
return Profile{}, fmt.Errorf("could not parse owner for path: %s: %w", path, err)
}
// The name falls back to the ID, which is what a legacy profile written
// before the field existed has.
name := meta.Name
if name == "" {
name = id.String()
}
return Profile{
ID: id,
Name: name,
Path: path,
Owners: owners,
LegacyUserDir: legacyUser,
}, nil
} }
// readProfileOwners parses the owner principals from a profile JSON. Owners stay // parseProfileFile reads a profile whose ID is its filename stem, which is
// principals so they are never mistaken for a kernel-attested caller. // every profile except the default one.
func parseProfileFile(path, legacyUser string) (Profile, error) {
stem := ID(strings.TrimSuffix(filepath.Base(path), ".json"))
if !IsValidProfileFilenameStem(stem) {
return Profile{}, fmt.Errorf("invalid profile ID: %q", stem)
}
return parseProfile(path, stem, legacyUser)
}
// parseDefaultProfile reads the default profile, which is the one profile that
// is allowed not to exist yet: the daemon writes it on first run, and a file
// that is not there is unowned rather than unreadable. Every listing before the
// first run would otherwise fail.
func parseDefaultProfile() (Profile, error) {
profile, err := parseProfile(DefaultConfigPath, defaultProfileName, "")
if errors.Is(err, os.ErrNotExist) {
return Profile{
ID: defaultProfileName,
Name: defaultProfileName,
Path: DefaultConfigPath,
}, nil
}
return profile, err
}
// parseOwners turns the recorded owners into principals. Owners stay principals
// so they are never mistaken for a kernel-attested caller.
// //
// Only the first entry is read. The field is a list on disk so multiple owners // Only the first entry is read. The field is a list on disk so multiple owners
// can be added later without a format change, but multiple owners are not // can be added later without a format change, but multiple owners are not
// supported yet. // supported yet.
func readProfileOwners(path string) ([]ipcauth.Principal, error) { func parseOwners(owners []string) ([]ipcauth.Principal, error) {
data, err := os.ReadFile(path) if len(owners) == 0 {
if err != nil {
return nil, err
}
var meta ownerMeta
if err := json.Unmarshal(data, &meta); err != nil {
return nil, err
}
if len(meta.Owners) == 0 {
return nil, nil return nil, nil
} }
principal, ok := ipcauth.ParsePrincipal(meta.Owners[0]) principal, ok := ipcauth.ParsePrincipal(owners[0])
if !ok { if !ok {
// An entry that cannot be parsed is not trusted, and it is not an // 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 // absence of ownership either: the profile records an owner that cannot
// be matched against anyone. // be matched against anyone.
return nil, fmt.Errorf("unparseable owner %q in %s", meta.Owners[0], path) return nil, fmt.Errorf("unparseable owner %q", owners[0])
} }
return []ipcauth.Principal{principal}, nil return []ipcauth.Principal{principal}, nil
} }
@@ -1106,36 +1137,90 @@ func (s *ServiceManager) ProfileByPath(path string) (*Profile, error) {
return nil, fmt.Errorf("profile path is empty") return nil, fmt.Errorf("profile path is empty")
} }
profiles, err := s.loadAllProfiles() legacyUser, ok, err := s.profileDirOf(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !ok {
for i := range profiles { return nil, fmt.Errorf("path %q is not in a profile directory", path)
if profiles[i].Path == path {
return &profiles[i], nil
}
} }
return nil, ErrProfileNotFound if filepath.Clean(path) == filepath.Clean(DefaultConfigPath) {
defaultProfile, err := parseDefaultProfile()
if err != nil {
return nil, err
}
return &defaultProfile, nil
}
profile, err := parseProfileFile(path, legacyUser)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrProfileNotFound
}
if err != nil {
return nil, err
}
return &profile, nil
} }
// ProfileByID returns the first profile with this ID. Only a caller that has no // profileDirOf reports whether a path is a profile file in one of the
// second profile to confuse it with may use this: a legacy ID is a display name // directories profiles live in, and which legacy account's directory that is.
// and two accounts can hold the same one. Prefer ProfileByPath. func (s *ServiceManager) profileDirOf(path string) (legacyUser string, ok bool, err error) {
clean := filepath.Clean(path)
if clean == filepath.Clean(DefaultConfigPath) {
return "", true, nil
}
dirs, err := s.profileDirs()
if err != nil {
return "", false, err
}
parent := filepath.Dir(clean)
for _, dir := range dirs {
if filepath.Clean(dir.path) == parent {
return dir.legacyUser, true, nil
}
}
return "", false, nil
}
// ProfileByID returns the first profile with this ID, reading only the files
// that could hold it rather than every profile on the machine.
//
// A legacy ID is a display name and two accounts can hold the same one in their
// own directories, so a caller that might be looking at somebody else's
// namesake wants ProfileByPath instead.
func (s *ServiceManager) ProfileByID(id ID) (*Profile, error) { func (s *ServiceManager) ProfileByID(id ID) (*Profile, error) {
if id == "" { if id == "" {
return nil, fmt.Errorf("profile ID is empty") return nil, fmt.Errorf("profile ID is empty")
} }
if id == defaultProfileName {
defaultProfile, err := parseDefaultProfile()
if err != nil {
return nil, err
}
return &defaultProfile, nil
}
if !IsValidProfileFilenameStem(id) {
return nil, fmt.Errorf("invalid profile ID: %q", id)
}
profiles, err := s.loadAllProfiles() dirs, err := s.profileDirs()
if err != nil { if err != nil {
return nil, err return nil, err
} }
for i := range profiles { for _, dir := range dirs {
if profiles[i].ID == id { path := filepath.Join(dir.path, id.String()+".json")
return &profiles[i], nil profile, err := parseProfile(path, id, dir.legacyUser)
switch {
case err == nil:
return &profile, nil
case errors.Is(err, os.ErrNotExist):
continue
default:
log.Warnf("skipping profile %s: %v", path, err)
} }
} }
+31 -1
View File
@@ -399,7 +399,7 @@ func readOwners(t *testing.T, path string) []string {
t.Helper() t.Helper()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
require.NoError(t, err) require.NoError(t, err)
var meta ownerMeta var meta profileMeta
require.NoError(t, json.Unmarshal(data, &meta)) require.NoError(t, json.Unmarshal(data, &meta))
return meta.Owners return meta.Owners
} }
@@ -924,3 +924,33 @@ func TestClaimDefaultProfile_UnparseableDisableEnvLeavesTheClaimOn(t *testing.T)
"a typo must not be what turns a safety mechanism off") "a typo must not be what turns a safety mechanism off")
}) })
} }
// The default profile's file is named by the platform, not after its ID: the
// mobile bindings call it netbird.cfg. Deriving the ID from the filename drops
// the profile out of every listing there, and the name has no ".json" to trim
// so the stem is rejected outright.
func TestLoadAllProfiles_DefaultProfileIsNotNamedAfterItsFile(t *testing.T) {
withTempConfigDir(t, func(configDir string) {
withPatchedGlobals(t, configDir, func() {
original := DefaultConfigPath
DefaultConfigPath = filepath.Join(configDir, "netbird.cfg")
t.Cleanup(func() { DefaultConfigPath = original })
sm := &ServiceManager{}
require.NoError(t, sm.CreateDefaultProfile())
profiles, err := sm.loadAllProfiles()
require.NoError(t, err)
assert.Contains(t, profileIDs(profiles), defaultProfileName,
"the default profile is missing from the listing on a platform that names its file")
got, err := sm.ProfileByID(defaultProfileName)
require.NoError(t, err)
assert.Equal(t, DefaultConfigPath, got.Path)
byPath, err := sm.ProfileByPath(DefaultConfigPath)
require.NoError(t, err)
assert.Equal(t, ID(defaultProfileName), byPath.ID)
})
})
}