From 4b68a2a665518355ca3445e61ce99c6ae43e3247 Mon Sep 17 00:00:00 2001 From: Hakan Sariman Date: Mon, 23 Jun 2025 15:51:38 +0300 Subject: [PATCH] feat: add profile management commands for listing, adding, removing, and selecting profiles --- client/cmd/profile.go | 147 ++++++++++++++++++ client/cmd/root.go | 7 + .../internal/profilemanager/profilemanager.go | 99 ++++++++---- client/ui/profile.go | 4 +- 4 files changed, 226 insertions(+), 31 deletions(-) create mode 100644 client/cmd/profile.go diff --git a/client/cmd/profile.go b/client/cmd/profile.go new file mode 100644 index 000000000..94d9f8482 --- /dev/null +++ b/client/cmd/profile.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/util" +) + +var profileCmd = &cobra.Command{ + Use: "profile", + Short: "manage Netbird profiles", + Long: `Manage Netbird profiles, allowing you to list, switch, and remove profiles.`, +} + +var profileListCmd = &cobra.Command{ + Use: "list", + Short: "list all profiles", + Long: `List all available profiles in the Netbird client.`, + RunE: listProfilesFunc, +} + +var profileAddCmd = &cobra.Command{ + Use: "add ", + Short: "add a new profile", + Long: `Add a new profile to the Netbird client. The profile name must be unique.`, + Args: cobra.ExactArgs(1), + RunE: addProfileFunc, +} + +var profileRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "remove a profile", + Long: `Remove a profile from the Netbird client. The profile must not be active.`, + Args: cobra.ExactArgs(1), + RunE: removeProfileFunc, +} + +var profileSelectCmd = &cobra.Command{ + Use: "select ", + Short: "select a profile", + Long: `Select a profile to be the active profile in the Netbird client. The profile must exist.`, + Args: cobra.ExactArgs(1), + RunE: selectProfileFunc, +} + +func listProfilesFunc(cmd *cobra.Command, _ []string) error { + SetFlagsFromEnvVars(rootCmd) + SetFlagsFromEnvVars(cmd) + + cmd.SetOut(cmd.OutOrStdout()) + + err := util.InitLog(logLevel, "console") + if err != nil { + return err + } + + profileManager := profilemanager.NewProfileManager() + profiles, err := profileManager.ListProfiles() + if err != nil { + return err + } + + // list profiles, add a tick if the profile is active + cmd.Println("Found", len(profiles), "profiles:") + for _, profile := range profiles { + // use a cross to indicate the passive profiles + activeMarker := "✗" + if profile.IsActive { + activeMarker = "✓" + } + cmd.Println(activeMarker, profile.Name) + } + + return nil +} + +func addProfileFunc(cmd *cobra.Command, args []string) error { + SetFlagsFromEnvVars(rootCmd) + SetFlagsFromEnvVars(cmd) + + cmd.SetOut(cmd.OutOrStdout()) + + err := util.InitLog(logLevel, "console") + if err != nil { + return err + } + + profileManager := profilemanager.NewProfileManager() + profileName := args[0] + + err = profileManager.AddProfile(profilemanager.Profile{ + Name: profileName, + }) + if err != nil { + return err + } + + cmd.Println("Profile added successfully:", profileName) + return nil +} + +func removeProfileFunc(cmd *cobra.Command, args []string) error { + SetFlagsFromEnvVars(rootCmd) + SetFlagsFromEnvVars(cmd) + + cmd.SetOut(cmd.OutOrStdout()) + + err := util.InitLog(logLevel, "console") + if err != nil { + return err + } + + profileManager := profilemanager.NewProfileManager() + profileName := args[0] + + err = profileManager.RemoveProfile(profileName) + if err != nil { + return err + } + + cmd.Println("Profile removed successfully:", profileName) + return nil +} + +func selectProfileFunc(cmd *cobra.Command, args []string) error { + SetFlagsFromEnvVars(rootCmd) + SetFlagsFromEnvVars(cmd) + + cmd.SetOut(cmd.OutOrStdout()) + + err := util.InitLog(logLevel, "console") + if err != nil { + return err + } + + profileManager := profilemanager.NewProfileManager() + profileName := args[0] + + err = profileManager.SwitchProfile(profileName) + if err != nil { + return err + } + + cmd.Println("Profile switched successfully to:", profileName) + return nil +} diff --git a/client/cmd/root.go b/client/cmd/root.go index 16e445f4d..ba8d999fd 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -152,6 +152,7 @@ func init() { rootCmd.AddCommand(networksCMD) rootCmd.AddCommand(forwardingRulesCmd) rootCmd.AddCommand(debugCmd) + rootCmd.AddCommand(profileCmd) serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd) // service control commands are subcommands of service serviceCmd.AddCommand(installCmd, uninstallCmd) // service installer commands are subcommands of service @@ -167,6 +168,12 @@ func init() { debugCmd.AddCommand(forCmd) debugCmd.AddCommand(persistenceCmd) + // profile commands + profileCmd.AddCommand(profileListCmd) + profileCmd.AddCommand(profileAddCmd) + profileCmd.AddCommand(profileRemoveCmd) + profileCmd.AddCommand(profileSelectCmd) + upCmd.PersistentFlags().StringSliceVar(&natExternalIPs, externalIPMapFlag, nil, `Sets external IPs maps between local addresses and interfaces.`+ `You can specify a comma-separated list with a single IP and IP/IP or IP/Interface Name. `+ diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go index ca62f48a9..cdd6f99d5 100644 --- a/client/internal/profilemanager/profilemanager.go +++ b/client/internal/profilemanager/profilemanager.go @@ -4,13 +4,20 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" "strings" "sync" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/util" ) +const ( + defaultProfileName = "default" +) + type Profile struct { Name string Email string @@ -18,8 +25,7 @@ type Profile struct { } type ProfileManager struct { - mu sync.Mutex - activeProfile *Profile + mu sync.Mutex } func NewProfileManager() *ProfileManager { @@ -67,7 +73,7 @@ func (pm *ProfileManager) RemoveProfile(profileName string) error { return fmt.Errorf("failed to get active profile: %w", err) } - if activeProf.Name == profileName { + if activeProf != nil && activeProf.Name == profileName { return fmt.Errorf("cannot remove active profile: %s", profileName) } @@ -82,18 +88,8 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { pm.mu.Lock() defer pm.mu.Unlock() - if pm.activeProfile == nil { - return nil, ErrNoActiveProfile - } - - return pm.activeProfile, nil -} - -func (pm *ProfileManager) SetActiveProfile(profileName string) { - pm.mu.Lock() - defer pm.mu.Unlock() - - pm.activeProfile = &Profile{Name: profileName} + prof := pm.getActiveProfileState() + return &Profile{Name: prof}, nil } func (pm *ProfileManager) ListProfiles() ([]Profile, error) { @@ -115,7 +111,7 @@ func (pm *ProfileManager) ListProfiles() ([]Profile, error) { var profiles []Profile // add default profile always - profiles = append(profiles, Profile{Name: "default", IsActive: activeProfName == "default"}) + profiles = append(profiles, Profile{Name: "default", IsActive: activeProfName == "" || activeProfName == "default"}) for _, file := range files { profileName := strings.TrimSuffix(filepath.Base(file), ".json") var isActive bool @@ -128,24 +124,13 @@ func (pm *ProfileManager) ListProfiles() ([]Profile, error) { return profiles, nil } -// TODO(hakan): implement func (pm *ProfileManager) SwitchProfile(profileName string) error { - pm.mu.Lock() - defer pm.mu.Unlock() - - // Check if the profile exists - configDir, err := getConfigDir() - if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) + if err := pm.setActiveProfileState(profileName); err != nil { + return fmt.Errorf("failed to switch profile: %w", err) } - profPath := filepath.Join(configDir, profileName+".json") - if !fileExists(profPath) { - return ErrProfileNotFound - } + // TODO(hakan): implement the logic to switch the profile in the application - // Set the active profile - pm.activeProfile = &Profile{Name: profileName} return nil } @@ -159,3 +144,57 @@ func sanitazeUsername(username string) string { return r }, username) } + +func (pm *ProfileManager) getActiveProfileState() string { + + configDir, err := getConfigDir() + if err != nil { + log.Warnf("failed to get config directory: %v", err) + return defaultProfileName + } + + statePath := filepath.Join(configDir, "active_profile.txt") + + prof, err := os.ReadFile(statePath) + if err != nil { + if !os.IsNotExist(err) { + log.Warnf("failed to read active profile state: %v", err) + } else { + pm.setActiveProfileState(defaultProfileName) + } + return defaultProfileName + } + profileName := strings.TrimSpace(string(prof)) + + if profileName == "" { + log.Warnf("active profile state is empty, using default profile: %s", defaultProfileName) + return defaultProfileName + } + if !fileExists(filepath.Join(configDir, profileName+".json")) { + log.Warnf("active profile %s does not exist, using default profile: %s", profileName, defaultProfileName) + return defaultProfileName + } + return profileName +} + +func (pm *ProfileManager) setActiveProfileState(profileName string) error { + + configDir, err := getConfigDir() + if err != nil { + return fmt.Errorf("failed to get config directory: %w", err) + } + + profPath := filepath.Join(configDir, profileName+".json") + if !fileExists(profPath) { + return fmt.Errorf("profile %s does not exist", profileName) + } + + statePath := filepath.Join(configDir, "active_profile.txt") + + err = os.WriteFile(statePath, []byte(profileName), 0644) + if err != nil { + return fmt.Errorf("failed to write active profile state: %w", err) + } + + return nil +} diff --git a/client/ui/profile.go b/client/ui/profile.go index 98e454a65..70e150c1c 100644 --- a/client/ui/profile.go +++ b/client/ui/profile.go @@ -287,6 +287,7 @@ func (p *profileMenu) refresh() { item.Remove() item.cancel() } + p.profileSubItems = make([]*subItem, 0, len(profiles)) if p.manageProfilesSubItem != nil { // Remove the manage profiles item if it exists @@ -327,7 +328,7 @@ func (p *profileMenu) refresh() { return } log.Infof("Switched to profile '%s'", profile.Name) - p.profileMenuItem.SetTitle(profile.Name) + p.refresh() // TODO(hakan): update email menu item if needed } } @@ -351,6 +352,7 @@ func (p *profileMenu) refresh() { } // Handle manage profiles click p.eventHandler.runSelfCommand("profiles", "true") + p.refresh() } } }()