mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-02 04:51:29 +02:00
326 lines
11 KiB
Go
326 lines
11 KiB
Go
// 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 (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/mdm"
|
|
)
|
|
|
|
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"
|
|
)
|
|
|
|
// ErrProfilesDisabled marks a profile mutation rejected by MDM policy.
|
|
var ErrProfilesDisabled = errors.New("profile management is disabled by MDM policy")
|
|
|
|
/*
|
|
|
|
<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
|
|
mdmLoader *mdm.Loader
|
|
}
|
|
|
|
// 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.checkProfilesAllowed(); err != nil {
|
|
return err
|
|
}
|
|
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) {
|
|
if err := pm.checkProfilesAllowed(); err != nil {
|
|
return nil, err
|
|
}
|
|
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.checkProfilesAllowed(); err != nil {
|
|
return err
|
|
}
|
|
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 {
|
|
if err := pm.checkProfilesAllowed(); err != nil {
|
|
return err
|
|
}
|
|
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.
|
|
// SetMDMLoader registers the MDM policy source consulted before profile
|
|
// mutations; a nil loader disables enforcement.
|
|
func (pm *ProfileManager) SetMDMLoader(loader *mdm.Loader) {
|
|
pm.mdmLoader = loader
|
|
}
|
|
|
|
func (pm *ProfileManager) checkProfilesAllowed() error {
|
|
if v, ok := pm.mdmLoader.Load().GetBool(mdm.KeyDisableProfiles); ok && v {
|
|
return ErrProfilesDisabled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|