mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-28 18:41:30 +02:00
[client] Extract the mobile profile manager into client/mobile
The Android and iOS gomobile bindings carried two near-identical copies of the profile manager. Move the shared implementation into a new client/mobile package and reduce both bindings to thin adapters that only translate to gomobile-friendly types (gomobile binds per package, so the Profile / ProfileArray wrappers have to stay platform-side). Also bring the account-email layer over to the shared package: an SSO login records the account under <stem>.account.json so the next login can pass it as an OIDC login_hint. Logout keeps it, profile removal drops it. The suffix deliberately differs from .state.json, which the engine's state manager owns in the same directory on mobile. Adds profilemanager.Prefs (namespaced per-profile preference store) and its cleanup in ServiceManager.RemoveProfile, exposed through the shared manager as ProfilePrefs.
This commit is contained in:
@@ -3,42 +3,37 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mobile"
|
||||
)
|
||||
|
||||
const (
|
||||
// Android-specific config filename (different from desktop default.json)
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory for non-default profiles (must match Java Preferences.java)
|
||||
profilesSubdir = "profiles"
|
||||
// Android uses a single user context per app (non-empty username required by ServiceManager)
|
||||
// Android uses a single user context per app.
|
||||
androidUsername = "android"
|
||||
)
|
||||
|
||||
// Profile represents a profile for gomobile
|
||||
// Profile represents a profile for gomobile.
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login. Kept across logouts; cleared when the profile is
|
||||
// removed. See client/mobile/profile_state.go.
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
// ProfileArray wraps profiles for gomobile compatibility
|
||||
// ProfileArray wraps profiles for gomobile compatibility (gomobile cannot
|
||||
// bind Go slices directly).
|
||||
type ProfileArray struct {
|
||||
items []*Profile
|
||||
}
|
||||
|
||||
// Length returns the number of profiles
|
||||
// Length returns the number of profiles.
|
||||
func (p *ProfileArray) Length() int {
|
||||
return len(p.items)
|
||||
}
|
||||
|
||||
// Get returns the profile at index i
|
||||
// Get returns the profile at index i, or nil if out of range.
|
||||
func (p *ProfileArray) Get(i int) *Profile {
|
||||
if i < 0 || i >= len(p.items) {
|
||||
return nil
|
||||
@@ -46,214 +41,98 @@ func (p *ProfileArray) Get(i int) *Profile {
|
||||
return p.items[i]
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
/data/data/io.netbird.client/files/ ← configDir parameter
|
||||
├── netbird.cfg ← Default profile config
|
||||
├── state.json ← Default profile state
|
||||
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
|
||||
└── profiles/ ← Subdirectory for non-default profiles
|
||||
├── work.json ← Legacy work profile config
|
||||
├── work.state.json ← Legacy work profile state
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
|
||||
*/
|
||||
|
||||
// ProfileManager manages profiles for Android
|
||||
// It wraps the internal profilemanager to provide Android-specific behavior
|
||||
// ProfileManager adapts the shared mobile profile manager (client/mobile) to
|
||||
// gomobile-friendly types. See that package for the on-disk layout and
|
||||
// semantics.
|
||||
type ProfileManager struct {
|
||||
configDir string
|
||||
serviceMgr *profilemanager.ServiceManager
|
||||
impl *mobile.ProfileManager
|
||||
}
|
||||
|
||||
// NewProfileManager creates a new profile manager for Android
|
||||
// NewProfileManager creates a new profile manager for Android. configDir is
|
||||
// the app's files directory.
|
||||
func NewProfileManager(configDir string) *ProfileManager {
|
||||
// Set the default config path for Android (stored in root configDir, not profiles/)
|
||||
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
|
||||
|
||||
// Set global paths for Android
|
||||
profilemanager.DefaultConfigPathDir = configDir
|
||||
profilemanager.DefaultConfigPath = defaultConfigPath
|
||||
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
|
||||
|
||||
// Create ServiceManager with profiles/ subdirectory
|
||||
// This avoids modifying the global ConfigDirOverride for profile listing
|
||||
profilesDir := filepath.Join(configDir, profilesSubdir)
|
||||
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
|
||||
|
||||
return &ProfileManager{
|
||||
configDir: configDir,
|
||||
serviceMgr: serviceMgr,
|
||||
}
|
||||
return &ProfileManager{impl: mobile.NewProfileManager(configDir, androidUsername)}
|
||||
}
|
||||
|
||||
// ListProfiles returns all available profiles
|
||||
// ListProfiles returns all available profiles, including the default profile,
|
||||
// with their active status set.
|
||||
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
// Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive)
|
||||
internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername)
|
||||
profiles, err := pm.impl.ListProfiles()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list profiles: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert internal profiles to Android Profile type
|
||||
var profiles []*Profile
|
||||
for _, p := range internalProfiles {
|
||||
profiles = append(profiles, &Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
items := make([]*Profile, 0, len(profiles))
|
||||
for i := range profiles {
|
||||
items = append(items, fromMobileProfile(&profiles[i]))
|
||||
}
|
||||
|
||||
return &ProfileArray{items: profiles}, nil
|
||||
return &ProfileArray{items: items}, nil
|
||||
}
|
||||
|
||||
// GetActiveProfile returns the currently active profile name
|
||||
// GetActiveProfile returns the currently active profile.
|
||||
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
// Use ServiceManager to stay consistent with ListProfiles
|
||||
// ServiceManager uses active_profile.json
|
||||
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
||||
p, err := pm.impl.GetActiveProfile()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active profile: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ActiveProfileState only stores the ID (and username), not the display
|
||||
// name. Resolve the ID to the full profile so callers get the real Name.
|
||||
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
|
||||
}
|
||||
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
|
||||
return fromMobileProfile(p), nil
|
||||
}
|
||||
|
||||
// SwitchProfile switches to a different profile
|
||||
// SwitchProfile records the given profile ID as the active profile. The caller
|
||||
// must stop the VPN tunnel before switching.
|
||||
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
// Use ServiceManager to stay consistent with ListProfiles
|
||||
// ServiceManager uses active_profile.json
|
||||
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: profilemanager.ID(id),
|
||||
Username: androidUsername,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to switch profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("switched to profile: %s", id)
|
||||
return nil
|
||||
return pm.impl.SwitchProfile(id)
|
||||
}
|
||||
|
||||
// AddProfile creates a new profile
|
||||
// AddProfile creates a new profile with the given display name and a
|
||||
// generated ID.
|
||||
func (pm *ProfileManager) AddProfile(profileName string) error {
|
||||
// Use ServiceManager (creates profile in profiles/ directory)
|
||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("created new profile: %s", profile.ID)
|
||||
return nil
|
||||
_, err := pm.impl.AddProfile(profileName)
|
||||
return err
|
||||
}
|
||||
|
||||
// LogoutProfile logs out from a profile (clears authentication)
|
||||
// RenameProfile changes the display name of the profile identified by id. The
|
||||
// on-disk filename (the ID) is left unchanged.
|
||||
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||
return pm.impl.RenameProfile(id, newName)
|
||||
}
|
||||
|
||||
// LogoutProfile clears authentication data for a profile, forcing a re-login.
|
||||
// The management URL and other settings are preserved.
|
||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return fmt.Errorf("id '%s' is not valid", id)
|
||||
}
|
||||
|
||||
// Check if profile exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("profile '%s' does not exist", id)
|
||||
}
|
||||
|
||||
// Read current config using internal profilemanager
|
||||
config, err := profilemanager.ReadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read profile config: %w", err)
|
||||
}
|
||||
|
||||
// Clear authentication by removing private key and SSH key
|
||||
config.PrivateKey = ""
|
||||
config.SSHKey = ""
|
||||
|
||||
// Save config using internal profilemanager
|
||||
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
return pm.impl.LogoutProfile(id)
|
||||
}
|
||||
|
||||
// RemoveProfile deletes a profile
|
||||
// RemoveProfile deletes a profile. The default profile and the active profile
|
||||
// cannot be removed.
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
// Use ServiceManager (removes profile from profiles/ directory)
|
||||
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
|
||||
return fmt.Errorf("failed to remove profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("removed profile: %s", id)
|
||||
return nil
|
||||
return pm.impl.RemoveProfile(id)
|
||||
}
|
||||
|
||||
// getProfileConfigPath returns the config file path for a profile
|
||||
// This is needed for Android-specific path handling (netbird.cfg for default profile)
|
||||
func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
if id == profilemanager.DefaultProfileName {
|
||||
// Android uses netbird.cfg for default profile instead of default.json
|
||||
// Default profile is stored in root configDir, not in profiles/
|
||||
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, id+".json"), nil
|
||||
}
|
||||
|
||||
// GetConfigPath returns the config file path for a given profile id
|
||||
// Java should call this instead of constructing paths with Preferences.configFile()
|
||||
// GetConfigPath returns the config file path for the given profile ID. Java
|
||||
// should call this instead of constructing paths with Preferences.configFile().
|
||||
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
|
||||
return pm.getProfileConfigPath(id)
|
||||
return pm.impl.GetConfigPath(id)
|
||||
}
|
||||
|
||||
// GetStateFilePath returns the state file path for a given profile
|
||||
// Java should call this instead of constructing paths with Preferences.stateFile()
|
||||
// GetStateFilePath returns the state file path for the given profile ID. Java
|
||||
// should call this instead of constructing paths with Preferences.stateFile().
|
||||
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
||||
if id == "" || id == profilemanager.DefaultProfileName {
|
||||
return filepath.Join(pm.configDir, "state.json"), nil
|
||||
}
|
||||
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, id+".state.json"), nil
|
||||
return pm.impl.GetStateFilePath(id)
|
||||
}
|
||||
|
||||
// GetActiveConfigPath returns the config file path for the currently active profile
|
||||
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile()
|
||||
// GetActiveConfigPath returns the config file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||
}
|
||||
return pm.GetConfigPath(activeProfile.ID)
|
||||
return pm.impl.GetActiveConfigPath()
|
||||
}
|
||||
|
||||
// GetActiveStateFilePath returns the state file path for the currently active profile
|
||||
// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile()
|
||||
// GetActiveStateFilePath returns the state file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get active profile: %w", err)
|
||||
}
|
||||
return pm.GetStateFilePath(activeProfile.ID)
|
||||
return pm.impl.GetActiveStateFilePath()
|
||||
}
|
||||
|
||||
func fromMobileProfile(p *mobile.Profile) *Profile {
|
||||
return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive}
|
||||
}
|
||||
|
||||
130
client/internal/profilemanager/prefs.go
Normal file
130
client/internal/profilemanager/prefs.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const prefsFileSuffix = ".prefs.json"
|
||||
|
||||
var prefsMu sync.Mutex
|
||||
|
||||
// Prefs is a namespaced per-profile preference store backed by a single JSON
|
||||
// file next to the profile config; it is deleted together with the profile.
|
||||
type Prefs struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// ProfilePrefs returns the preference store of the profile identified by id.
|
||||
func (s *ServiceManager) ProfilePrefs(id ID, username string) (*Prefs, error) {
|
||||
if !IsValidProfileFilenameStem(id) {
|
||||
return nil, fmt.Errorf("invalid profile ID: %q", id)
|
||||
}
|
||||
if id == defaultProfileName {
|
||||
return &Prefs{path: filepath.Join(filepath.Dir(DefaultConfigPath), id.String()+prefsFileSuffix)}, nil
|
||||
}
|
||||
configDir, err := s.getConfigDir(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config directory for user %s: %w", username, err)
|
||||
}
|
||||
return &Prefs{path: filepath.Join(configDir, id.String()+prefsFileSuffix)}, nil
|
||||
}
|
||||
|
||||
// Get unmarshals the namespace section into v and reports whether it exists.
|
||||
func (p *Prefs) Get(namespace string, v any) (bool, error) {
|
||||
if namespace == "" {
|
||||
return false, fmt.Errorf("empty prefs namespace")
|
||||
}
|
||||
|
||||
prefsMu.Lock()
|
||||
defer prefsMu.Unlock()
|
||||
|
||||
sections, err := readPrefsFile(p.path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
raw, ok := sections[namespace]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, v); err != nil {
|
||||
return false, fmt.Errorf("decode prefs namespace %q: %w", namespace, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Put stores v as the namespace section, replacing any previous value.
|
||||
func (p *Prefs) Put(namespace string, v any) error {
|
||||
if namespace == "" {
|
||||
return fmt.Errorf("empty prefs namespace")
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode prefs namespace %q: %w", namespace, err)
|
||||
}
|
||||
|
||||
prefsMu.Lock()
|
||||
defer prefsMu.Unlock()
|
||||
|
||||
sections, err := readPrefsFile(p.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sections[namespace] = raw
|
||||
return writePrefsFile(p.path, sections)
|
||||
}
|
||||
|
||||
// Remove deletes the namespace section; a missing one is not an error.
|
||||
func (p *Prefs) Remove(namespace string) error {
|
||||
if namespace == "" {
|
||||
return fmt.Errorf("empty prefs namespace")
|
||||
}
|
||||
|
||||
prefsMu.Lock()
|
||||
defer prefsMu.Unlock()
|
||||
|
||||
sections, err := readPrefsFile(p.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := sections[namespace]; !ok {
|
||||
return nil
|
||||
}
|
||||
delete(sections, namespace)
|
||||
return writePrefsFile(p.path, sections)
|
||||
}
|
||||
|
||||
func removePrefsFile(path string) error {
|
||||
prefsMu.Lock()
|
||||
defer prefsMu.Unlock()
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func readPrefsFile(path string) (map[string]json.RawMessage, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]json.RawMessage{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read prefs: %w", err)
|
||||
}
|
||||
|
||||
sections := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(data, §ions); err != nil {
|
||||
return nil, fmt.Errorf("decode prefs: %w", err)
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
func writePrefsFile(path string, sections map[string]json.RawMessage) error {
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, sections); err != nil {
|
||||
return fmt.Errorf("write prefs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
138
client/internal/profilemanager/prefs_test.go
Normal file
138
client/internal/profilemanager/prefs_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testPrefsSection struct {
|
||||
Mode uint8 `json:"mode"`
|
||||
Dest string `json:"dest"`
|
||||
}
|
||||
|
||||
func TestProfilePrefs_RoundTrip(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
|
||||
prefs, err := sm.ProfilePrefs(created.ID, username)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 2, Dest: "/tmp/x"}))
|
||||
require.NoError(t, prefs.Put("other", map[string]int{"n": 1}))
|
||||
|
||||
var got testPrefsSection
|
||||
found, err := prefs.Get("filedrop", &got)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, testPrefsSection{Mode: 2, Dest: "/tmp/x"}, got)
|
||||
|
||||
var other map[string]int
|
||||
found, err = prefs.Get("other", &other)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, map[string]int{"n": 1}, other)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfilePrefs_GetMissingNamespace(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
|
||||
prefs, err := sm.ProfilePrefs(created.ID, username)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got testPrefsSection
|
||||
found, err := prefs.Get("filedrop", &got)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfilePrefs_RemoveNamespace(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
require.NoError(t, err)
|
||||
|
||||
prefs, err := sm.ProfilePrefs(created.ID, username)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1}))
|
||||
require.NoError(t, prefs.Put("other", map[string]int{"n": 1}))
|
||||
require.NoError(t, prefs.Remove("filedrop"))
|
||||
require.NoError(t, prefs.Remove("missing"))
|
||||
|
||||
var got testPrefsSection
|
||||
found, err := prefs.Get("filedrop", &got)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
|
||||
var other map[string]int
|
||||
found, err = prefs.Get("other", &other)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, map[string]int{"n": 1}, other)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfilePrefs_RejectsInvalidID(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
_, 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)
|
||||
require.NoError(t, err)
|
||||
|
||||
prefs, err := sm.ProfilePrefs(created.ID, username)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = prefs.Get("", &testPrefsSection{})
|
||||
assert.Error(t, err)
|
||||
assert.Error(t, prefs.Put("", testPrefsSection{}))
|
||||
assert.Error(t, prefs.Remove(""))
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfilePrefs_DefaultProfile(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
prefs, err := sm.ProfilePrefs(defaultProfileName, username)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, prefs.Put("filedrop", testPrefsSection{Mode: 1}))
|
||||
|
||||
expected := filepath.Join(filepath.Dir(DefaultConfigPath), "default"+prefsFileSuffix)
|
||||
_, err = os.Stat(expected)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveProfile_DeletesPrefsFile(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
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)
|
||||
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))
|
||||
_, err = os.Stat(prefsPath)
|
||||
assert.True(t, errors.Is(err, os.ErrNotExist), "prefs file should be removed")
|
||||
})
|
||||
}
|
||||
@@ -419,6 +419,11 @@ func (s *ServiceManager) RemoveProfile(id ID, username string) error {
|
||||
log.Warnf("failed to remove profile state file %s: %v", stateFile, err)
|
||||
}
|
||||
|
||||
prefsFile := filepath.Join(filepath.Dir(target.Path), id.String()+prefsFileSuffix)
|
||||
if err := removePrefsFile(prefsFile); err != nil && !os.IsNotExist(err) {
|
||||
log.Warnf("failed to remove profile prefs file %s: %v", prefsFile, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,25 +3,11 @@
|
||||
package NetBirdSDK
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/mobile"
|
||||
)
|
||||
|
||||
const (
|
||||
// iOS-specific config filename for the default profile (matches the
|
||||
// Swift GlobalConstants.configFileName, and the desktop netbird.cfg
|
||||
// rather than default.json).
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory for non-default profiles (must match the Swift profiles
|
||||
// directory layout).
|
||||
profilesSubdir = "profiles"
|
||||
// iOS uses a single user context per app (a non-empty username is
|
||||
// required by ServiceManager for non-default profiles).
|
||||
// iOS uses a single user context per app.
|
||||
iosUsername = "ios"
|
||||
)
|
||||
|
||||
@@ -29,6 +15,7 @@ const (
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
@@ -51,214 +38,101 @@ func (p *ProfileArray) Get(i int) *Profile {
|
||||
return p.items[i]
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
<App Group container>/ ← configDir parameter
|
||||
├── netbird.cfg ← Default profile config
|
||||
├── state.json ← Default profile state
|
||||
├── active_profile.json ← Active profile tracker (JSON with ID + Username)
|
||||
└── profiles/ ← Subdirectory for non-default profiles
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
|
||||
└── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
|
||||
*/
|
||||
|
||||
// ProfileManager manages profiles for iOS. It wraps the internal
|
||||
// profilemanager.ServiceManager to provide iOS-specific path handling and a
|
||||
// gomobile-friendly API. All profile identity is ID-based; the human-readable
|
||||
// name lives inside the profile config's Name field.
|
||||
// ProfileManager adapts the shared mobile profile manager (client/mobile) to
|
||||
// gomobile-friendly types. See that package for the on-disk layout and
|
||||
// semantics.
|
||||
type ProfileManager struct {
|
||||
configDir string
|
||||
serviceMgr *profilemanager.ServiceManager
|
||||
impl *mobile.ProfileManager
|
||||
}
|
||||
|
||||
// NewProfileManager creates a new profile manager for iOS. configDir is the
|
||||
// App Group shared container path that both the app and the network extension
|
||||
// can reach.
|
||||
func NewProfileManager(configDir string) *ProfileManager {
|
||||
// The default profile is stored in the root configDir, not under profiles/.
|
||||
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
|
||||
|
||||
// Point the package globals at the app-provided container, overriding the
|
||||
// desktop defaults set in profilemanager's init().
|
||||
profilemanager.DefaultConfigPathDir = configDir
|
||||
profilemanager.DefaultConfigPath = defaultConfigPath
|
||||
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
|
||||
|
||||
// Non-default profiles live in the profiles/ subdirectory. Passing it
|
||||
// explicitly avoids touching the global config-dir override.
|
||||
profilesDir := filepath.Join(configDir, profilesSubdir)
|
||||
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
|
||||
|
||||
return &ProfileManager{
|
||||
configDir: configDir,
|
||||
serviceMgr: serviceMgr,
|
||||
}
|
||||
return &ProfileManager{impl: mobile.NewProfileManager(configDir, iosUsername)}
|
||||
}
|
||||
|
||||
// ListProfiles returns all available profiles, including the default profile,
|
||||
// with their active status set.
|
||||
func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
|
||||
internalProfiles, err := pm.serviceMgr.ListProfiles(iosUsername)
|
||||
profiles, err := pm.impl.ListProfiles()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list profiles: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var profiles []*Profile
|
||||
for _, p := range internalProfiles {
|
||||
profiles = append(profiles, &Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
items := make([]*Profile, 0, len(profiles))
|
||||
for i := range profiles {
|
||||
items = append(items, fromMobileProfile(&profiles[i]))
|
||||
}
|
||||
|
||||
return &ProfileArray{items: profiles}, nil
|
||||
return &ProfileArray{items: items}, nil
|
||||
}
|
||||
|
||||
// GetActiveProfile returns the currently active profile, resolving its ID to
|
||||
// the full profile so callers get the real display name.
|
||||
// GetActiveProfile returns the currently active profile.
|
||||
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
||||
p, err := pm.impl.GetActiveProfile()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active profile: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), iosUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve active profile %q: %w", activeState.ID, err)
|
||||
}
|
||||
return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
|
||||
return fromMobileProfile(p), nil
|
||||
}
|
||||
|
||||
// SwitchProfile records the given profile ID as the active profile. The caller
|
||||
// must stop the VPN tunnel before switching.
|
||||
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: profilemanager.ID(id),
|
||||
Username: iosUsername,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("switch profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("switched to profile: %s", id)
|
||||
return nil
|
||||
return pm.impl.SwitchProfile(id)
|
||||
}
|
||||
|
||||
// AddProfile creates a new profile with the given display name and a
|
||||
// generated ID. It returns the created profile so the caller learns the ID.
|
||||
func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
|
||||
profile, err := pm.serviceMgr.AddProfile(displayName, iosUsername)
|
||||
p, err := pm.impl.AddProfile(displayName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add profile: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Infof("created new profile: %s", profile.ID)
|
||||
return &Profile{ID: profile.ID.String(), Name: profile.Name, IsActive: false}, nil
|
||||
return fromMobileProfile(p), nil
|
||||
}
|
||||
|
||||
// RenameProfile changes the display name of the profile identified by id. The
|
||||
// on-disk filename (the ID) is left unchanged.
|
||||
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), iosUsername, newName); err != nil {
|
||||
return fmt.Errorf("rename profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("renamed profile %s to %q", id, newName)
|
||||
return nil
|
||||
return pm.impl.RenameProfile(id, newName)
|
||||
}
|
||||
|
||||
// LogoutProfile clears authentication data for a profile by removing its
|
||||
// private key and SSH key from the config, forcing a re-login. The management
|
||||
// URL and other settings are preserved.
|
||||
// LogoutProfile clears authentication data for a profile, forcing a re-login.
|
||||
// The management URL and other settings are preserved.
|
||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("profile %q does not exist", id)
|
||||
}
|
||||
|
||||
config, err := profilemanager.ReadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read profile config: %w", err)
|
||||
}
|
||||
|
||||
config.PrivateKey = ""
|
||||
config.SSHKey = ""
|
||||
|
||||
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
return pm.impl.LogoutProfile(id)
|
||||
}
|
||||
|
||||
// RemoveProfile deletes a profile. The default profile and the active profile
|
||||
// cannot be removed.
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), iosUsername); err != nil {
|
||||
return fmt.Errorf("remove profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("removed profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getProfileConfigPath returns the config file path for a profile ID. The
|
||||
// default profile uses netbird.cfg in the root configDir; other profiles use
|
||||
// <id>.json in the profiles/ subdirectory.
|
||||
func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
if id == profilemanager.DefaultProfileName {
|
||||
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, id+".json"), nil
|
||||
return pm.impl.RemoveProfile(id)
|
||||
}
|
||||
|
||||
// GetConfigPath returns the config file path for the given profile ID. Swift
|
||||
// should call this instead of constructing paths itself.
|
||||
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
|
||||
return pm.getProfileConfigPath(id)
|
||||
return pm.impl.GetConfigPath(id)
|
||||
}
|
||||
|
||||
// GetStateFilePath returns the state file path for the given profile ID.
|
||||
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
||||
if id == "" || id == profilemanager.DefaultProfileName {
|
||||
return filepath.Join(pm.configDir, "state.json"), nil
|
||||
}
|
||||
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, id+".state.json"), nil
|
||||
return pm.impl.GetStateFilePath(id)
|
||||
}
|
||||
|
||||
// GetActiveConfigPath returns the config file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
return pm.GetConfigPath(activeProfile.ID)
|
||||
return pm.impl.GetActiveConfigPath()
|
||||
}
|
||||
|
||||
// GetActiveStateFilePath returns the state file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
return pm.GetStateFilePath(activeProfile.ID)
|
||||
return pm.impl.GetActiveStateFilePath()
|
||||
}
|
||||
|
||||
func fromMobileProfile(p *mobile.Profile) *Profile {
|
||||
return &Profile{ID: p.ID, Name: p.Name, Email: p.Email, IsActive: p.IsActive}
|
||||
}
|
||||
|
||||
294
client/mobile/profile_manager.go
Normal file
294
client/mobile/profile_manager.go
Normal file
@@ -0,0 +1,294 @@
|
||||
// Package mobile holds the profile manager implementation shared by the
|
||||
// Android and iOS gomobile bindings. The platform packages (client/android,
|
||||
// client/ios/NetBirdSDK) only adapt this API to gomobile-friendly types.
|
||||
package mobile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
const (
|
||||
// Config filename of the default profile, stored at the configDir root.
|
||||
// Both platforms use netbird.cfg (matching the desktop netbird.cfg rather
|
||||
// than default.json); the app-side path constants must match.
|
||||
defaultConfigFilename = "netbird.cfg"
|
||||
// Subdirectory of configDir holding non-default profiles.
|
||||
profilesSubdir = "profiles"
|
||||
)
|
||||
|
||||
/*
|
||||
|
||||
<configDir>/ ← app-writable config root
|
||||
├── netbird.cfg ← Default profile config
|
||||
├── netbird.account.json ← Default profile account email (see profile_state.go)
|
||||
├── state.json ← Default profile state
|
||||
├── active_profile.json ← Active profile tracker (JSON with ID + Username)
|
||||
└── profiles/ ← Subdirectory for non-default profiles
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← Profile config (filename = ID)
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← Profile state
|
||||
├── 4c5f5c8198c3989cffb5b5394f5a7ae0.account.json ← Profile account email
|
||||
└── 4c5f5c8198c3989cffb5b5394f5a7ae0.prefs.json ← Profile preferences
|
||||
*/
|
||||
|
||||
// Profile is the platform-independent profile view handed to the bindings.
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login. Kept across logouts; cleared when the profile is
|
||||
// removed. See profile_state.go.
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
// ProfileManager manages profiles for the mobile platforms. It wraps the
|
||||
// internal profilemanager.ServiceManager with mobile-specific path handling.
|
||||
// All profile identity is ID-based; the human-readable name lives inside the
|
||||
// profile config's Name field.
|
||||
type ProfileManager struct {
|
||||
configDir string
|
||||
username string
|
||||
serviceMgr *profilemanager.ServiceManager
|
||||
}
|
||||
|
||||
// NewProfileManager creates a profile manager rooted at configDir, the
|
||||
// app-writable directory that every process of the app can reach. username is
|
||||
// the platform's fixed single-user context (a non-empty username is required
|
||||
// by ServiceManager for non-default profiles).
|
||||
func NewProfileManager(configDir, username string) *ProfileManager {
|
||||
// The default profile is stored in the root configDir, not under profiles/.
|
||||
defaultConfigPath := filepath.Join(configDir, defaultConfigFilename)
|
||||
|
||||
// Point the package globals at the app-provided directory, overriding the
|
||||
// desktop defaults set in profilemanager's init().
|
||||
profilemanager.DefaultConfigPathDir = configDir
|
||||
profilemanager.DefaultConfigPath = defaultConfigPath
|
||||
profilemanager.ActiveProfileStatePath = filepath.Join(configDir, "active_profile.json")
|
||||
|
||||
// Non-default profiles live in the profiles/ subdirectory. Passing it
|
||||
// explicitly avoids touching the global config-dir override.
|
||||
profilesDir := filepath.Join(configDir, profilesSubdir)
|
||||
serviceMgr := profilemanager.NewServiceManagerWithProfilesDir(defaultConfigPath, profilesDir)
|
||||
|
||||
return &ProfileManager{
|
||||
configDir: configDir,
|
||||
username: username,
|
||||
serviceMgr: serviceMgr,
|
||||
}
|
||||
}
|
||||
|
||||
// ListProfiles returns all available profiles, including the default profile,
|
||||
// with their active status set.
|
||||
func (pm *ProfileManager) ListProfiles() ([]Profile, error) {
|
||||
internalProfiles, err := pm.serviceMgr.ListProfiles(pm.username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list profiles: %w", err)
|
||||
}
|
||||
|
||||
profiles := make([]Profile, 0, len(internalProfiles))
|
||||
for _, p := range internalProfiles {
|
||||
profiles = append(profiles, Profile{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Email: pm.profileEmail(p.ID.String()),
|
||||
IsActive: p.IsActive,
|
||||
})
|
||||
}
|
||||
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// GetActiveProfile returns the currently active profile, resolving its ID to
|
||||
// the full profile so callers get the real display name.
|
||||
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
|
||||
activeState, err := pm.serviceMgr.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
|
||||
prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), pm.username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve active profile %q: %w", activeState.ID, err)
|
||||
}
|
||||
return &Profile{
|
||||
ID: prof.ID.String(),
|
||||
Name: prof.Name,
|
||||
Email: pm.profileEmail(prof.ID.String()),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SwitchProfile records the given profile ID as the active profile. The caller
|
||||
// must stop the VPN tunnel before switching.
|
||||
func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
if err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
||||
ID: profilemanager.ID(id),
|
||||
Username: pm.username,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("switch profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("switched to profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddProfile creates a new profile with the given display name and a
|
||||
// generated ID. It returns the created profile so the caller learns the ID.
|
||||
func (pm *ProfileManager) AddProfile(displayName string) (*Profile, error) {
|
||||
profile, err := pm.serviceMgr.AddProfile(displayName, pm.username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("created new profile: %s", profile.ID)
|
||||
return &Profile{ID: profile.ID.String(), Name: profile.Name, IsActive: false}, nil
|
||||
}
|
||||
|
||||
// RenameProfile changes the display name of the profile identified by id. The
|
||||
// on-disk filename (the ID) is left unchanged.
|
||||
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), pm.username, newName); err != nil {
|
||||
return fmt.Errorf("rename profile: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("renamed profile %s to %q", id, newName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogoutProfile clears authentication data for a profile by removing its
|
||||
// private key and SSH key from the config, forcing a re-login. The management
|
||||
// URL and other settings are preserved.
|
||||
func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("profile %q does not exist", id)
|
||||
}
|
||||
|
||||
config, err := profilemanager.ReadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read profile config: %w", err)
|
||||
}
|
||||
|
||||
config.PrivateKey = ""
|
||||
config.SSHKey = ""
|
||||
|
||||
if err := profilemanager.WriteOutConfig(configPath, config); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
// The stored account email is kept on purpose, matching the desktop and CLI
|
||||
// logout semantics: the next login passes it as the login_hint so the IdP
|
||||
// preselects the account. Removing the profile is what deletes it.
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProfile deletes a profile. The default profile and the active profile
|
||||
// cannot be removed.
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), pm.username); err != nil {
|
||||
return fmt.Errorf("remove profile: %w", err)
|
||||
}
|
||||
|
||||
// The account file is this package's, not the ServiceManager's, so it must
|
||||
// go here. The default profile has a fixed filename, so a recreated one
|
||||
// would otherwise inherit the deleted profile's email as its login_hint.
|
||||
// Not fatal: the profile itself is gone.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to remove stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Infof("removed profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProfilePrefs returns the namespaced per-profile preference store of the
|
||||
// profile identified by id.
|
||||
func (pm *ProfileManager) ProfilePrefs(id string) (*profilemanager.Prefs, error) {
|
||||
prefs, err := pm.serviceMgr.ProfilePrefs(profilemanager.ID(id), pm.username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve profile prefs: %w", err)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// GetConfigPath returns the config file path for the given profile ID. The
|
||||
// platform code should call this instead of constructing paths itself.
|
||||
func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
|
||||
return pm.getProfileConfigPath(id)
|
||||
}
|
||||
|
||||
// GetStateFilePath returns the state file path for the given profile ID.
|
||||
func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
|
||||
if id == "" || id == profilemanager.DefaultProfileName {
|
||||
return filepath.Join(pm.configDir, "state.json"), nil
|
||||
}
|
||||
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, id+".state.json"), nil
|
||||
}
|
||||
|
||||
// GetActiveConfigPath returns the config file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
return pm.GetConfigPath(activeProfile.ID)
|
||||
}
|
||||
|
||||
// GetActiveStateFilePath returns the state file path for the currently active
|
||||
// profile.
|
||||
func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
|
||||
activeProfile, err := pm.GetActiveProfile()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
return pm.GetStateFilePath(activeProfile.ID)
|
||||
}
|
||||
|
||||
// profileEmail returns the account email recorded for a profile. Display-only,
|
||||
// so an unresolvable path degrades to "" rather than an error.
|
||||
func (pm *ProfileManager) profileEmail(id string) string {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ReadProfileEmail(configPath)
|
||||
}
|
||||
|
||||
// getProfileConfigPath returns the config file path for a profile ID. The
|
||||
// default profile uses netbird.cfg in the root configDir; other profiles use
|
||||
// <id>.json in the profiles/ subdirectory.
|
||||
func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
|
||||
if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
|
||||
return "", fmt.Errorf("id %q is not valid", id)
|
||||
}
|
||||
|
||||
if id == profilemanager.DefaultProfileName {
|
||||
return filepath.Join(pm.configDir, defaultConfigFilename), nil
|
||||
}
|
||||
|
||||
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
|
||||
return filepath.Join(profilesDir, id+".json"), nil
|
||||
}
|
||||
104
client/mobile/profile_state.go
Normal file
104
client/mobile/profile_state.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package mobile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// profileAccountSuffix names the file holding the profile's account email.
|
||||
// Deliberately not ".state.json", which desktop uses for the same data:
|
||||
// there the email and the engine's state manager live in different
|
||||
// directories, but on mobile both resolve under configDir, so sharing the
|
||||
// name would have the two overwrite each other — the state manager rewrites
|
||||
// the whole file from its own keys (see statemanager.Manager.PersistState),
|
||||
// and this package's writer does the same in reverse.
|
||||
profileAccountSuffix = ".account.json"
|
||||
)
|
||||
|
||||
// profileAccountPathFor derives the account file path from a profile's config
|
||||
// path: netbird.cfg -> netbird.account.json, <id>.json -> <id>.account.json.
|
||||
//
|
||||
// Deriving from the config path rather than resolving the active profile keeps
|
||||
// the write on the profile the login actually ran for: login flows run in a
|
||||
// goroutine, so the active profile can change under a flow already in flight.
|
||||
func profileAccountPathFor(configPath string) (string, error) {
|
||||
if configPath == "" {
|
||||
return "", fmt.Errorf("empty config path")
|
||||
}
|
||||
|
||||
base := filepath.Base(configPath)
|
||||
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if stem == "" || stem == "." {
|
||||
return "", fmt.Errorf("config path %q has no filename stem", configPath)
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
|
||||
}
|
||||
|
||||
// ReadProfileEmail returns the account email stored for the profile whose
|
||||
// config lives at configPath. A missing or unreadable file yields "", which
|
||||
// leaves the account choice to the IdP.
|
||||
func ReadProfileEmail(configPath string) string {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
log.Debugf("no profile account path for login hint: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var state profilemanager.ProfileState
|
||||
if _, err := util.ReadJson(accountPath, &state); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Debugf("failed to read profile account for login hint: %v", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return state.Email
|
||||
}
|
||||
|
||||
// WriteProfileEmail records the account email for the profile whose config
|
||||
// lives at configPath, so later logins can pass it as an OIDC login_hint. An
|
||||
// empty email is ignored rather than blanking what is already stored.
|
||||
func WriteProfileEmail(configPath string, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
state := profilemanager.ProfileState{Email: email}
|
||||
if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
|
||||
return fmt.Errorf("write profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeProfileEmail drops the stored account email. Called on profile removal,
|
||||
// not on logout: a logged-out profile keeps its email so the next login passes
|
||||
// it as the login_hint, matching the desktop and CLI semantics. Mirrors the
|
||||
// desktop UI's RemoveProfileState call.
|
||||
func removeProfileEmail(configPath string) error {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve profile account path: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove profile account: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
161
client/mobile/profile_state_test.go
Normal file
161
client/mobile/profile_state_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package mobile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProfileAccountPathFor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configPath string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "default profile",
|
||||
configPath: "/data/netbird/files/netbird.cfg",
|
||||
want: filepath.FromSlash("/data/netbird/files/netbird.account.json"),
|
||||
},
|
||||
{
|
||||
name: "id profile",
|
||||
configPath: "/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
|
||||
want: filepath.FromSlash("/data/netbird/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
|
||||
},
|
||||
{
|
||||
name: "legacy name-keyed profile is handled the same way",
|
||||
configPath: "/data/netbird/files/profiles/work.json",
|
||||
want: filepath.FromSlash("/data/netbird/files/profiles/work.account.json"),
|
||||
},
|
||||
{
|
||||
name: "empty path is rejected",
|
||||
configPath: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := profileAccountPathFor(tt.configPath)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error, got path %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
|
||||
root := "/data/netbird/files"
|
||||
|
||||
defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
|
||||
if err != nil {
|
||||
t.Fatalf("default profile: %v", err)
|
||||
}
|
||||
|
||||
idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("id profile: %v", err)
|
||||
}
|
||||
|
||||
if defaultAccount == idAccount {
|
||||
t.Fatalf("default and id profile share an account file: %q", defaultAccount)
|
||||
}
|
||||
}
|
||||
|
||||
// The account file must never land on the engine state file: on mobile both
|
||||
// resolve under configDir, and the state manager rewrites the whole file from
|
||||
// its own keys, so sharing a path would have the two overwrite each other. The
|
||||
// expected names here mirror ProfileManager.GetStateFilePath.
|
||||
func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
|
||||
root := "/data/netbird/files"
|
||||
|
||||
cases := []struct {
|
||||
configPath string
|
||||
engineState string
|
||||
}{
|
||||
{
|
||||
configPath: filepath.Join(root, defaultConfigFilename),
|
||||
engineState: filepath.Join(root, "state.json"),
|
||||
},
|
||||
{
|
||||
configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
|
||||
engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
account, err := profileAccountPathFor(c.configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.configPath, err)
|
||||
}
|
||||
if account == c.engineState {
|
||||
t.Errorf("account file collides with the engine state file: %q", account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteThenReadProfileEmail(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
if got := ReadProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email before a login, got %q", got)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := WriteProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
if got := ReadProfileEmail(configPath); got != email {
|
||||
t.Errorf("got %q, want %q", got, email)
|
||||
}
|
||||
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if got := ReadProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email after removal, got %q", got)
|
||||
}
|
||||
|
||||
// Removal may run on a never-logged-in profile, so a second remove must pass.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("second remove should be a no-op: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
|
||||
if err := ensureDirFor(t, configPath); err != nil {
|
||||
t.Fatalf("prepare dir: %v", err)
|
||||
}
|
||||
|
||||
const email = "user@example.com"
|
||||
if err := WriteProfileEmail(configPath, email); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := WriteProfileEmail(configPath, ""); err != nil {
|
||||
t.Fatalf("write empty: %v", err)
|
||||
}
|
||||
|
||||
if got := ReadProfileEmail(configPath); got != email {
|
||||
t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDirFor(t *testing.T, path string) error {
|
||||
t.Helper()
|
||||
return os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
}
|
||||
Reference in New Issue
Block a user