diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 39791c8..3cada6f 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -7,14 +7,16 @@ import ( "github.com/spf13/cobra" ) -var AuthCmd = &cobra.Command{ - Use: "auth", - Short: "Authentication commands", - Long: "Manage authentication and sessions", -} +func AuthCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Authentication commands", + Long: "Manage authentication and sessions", + } + + cmd.AddCommand(login.LoginCmd()) + cmd.AddCommand(logout.LogoutCmd()) + cmd.AddCommand(status.StatusCmd()) -func init() { - AuthCmd.AddCommand(login.LoginCmd) - AuthCmd.AddCommand(logout.LogoutCmd) - AuthCmd.AddCommand(status.StatusCmd) + return cmd } diff --git a/cmd/auth/login/login.go b/cmd/auth/login/login.go index 4fe5a6c..463cc25 100644 --- a/cmd/auth/login/login.go +++ b/cmd/auth/login/login.go @@ -2,6 +2,7 @@ package login import ( "bufio" + "errors" "fmt" "os" "strings" @@ -140,159 +141,184 @@ func loginWithWeb(hostname string) (string, error) { } } -var LoginCmd = &cobra.Command{ - Use: "login [hostname]", - Short: "Login to Pangolin", - Long: "Interactive login to select your hosting option and configure access.", - Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - apiClient := api.FromContext(cmd.Context()) - accountStore := config.AccountStoreFromContext(cmd.Context()) +type LoginCmdOpts struct { + Hostname string +} + +func LoginCmd() *cobra.Command { + opts := LoginCmdOpts{} + + cmd := &cobra.Command{ + Use: "login [hostname]", + Short: "Login to Pangolin", + Long: "Interactive login to select your hosting option and configure access.", + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.MaximumNArgs(1)(cmd, args); err != nil { + return err + } + + if len(args) > 0 { + opts.Hostname = args[0] + } + + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + if err := loginMain(cmd, &opts); err != nil { + os.Exit(1) + } + }, + } + + return cmd +} + +func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error { + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + + hostname := opts.Hostname + // If hostname was provided, skip hosting option selection + if hostname == "" { var hostingOption HostingOption - var hostname string - // Check if hostname was provided as positional argument - if len(args) > 0 { - hostname = args[0] + // First question: select hosting option + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[HostingOption](). + Title("Select your hosting option"). + Options( + huh.NewOption("Pangolin Cloud (app.pangolin.net)", HostingOptionCloud), + huh.NewOption("Self-hosted or Dedicated instance", HostingOptionSelfHosted), + ). + Value(&hostingOption), + ), + ) + + if err := form.Run(); err != nil { + logger.Error("Error: %v", err) + return err } - // If hostname was provided, skip hosting option selection - if hostname == "" { - // First question: select hosting option - form := huh.NewForm( + // If self-hosted, prompt for hostname + if hostingOption == HostingOptionSelfHosted { + hostnameForm := huh.NewForm( huh.NewGroup( - huh.NewSelect[HostingOption](). - Title("Select your hosting option"). - Options( - huh.NewOption("Pangolin Cloud (app.pangolin.net)", HostingOptionCloud), - huh.NewOption("Self-hosted or Dedicated instance", HostingOptionSelfHosted), - ). - Value(&hostingOption), + huh.NewInput(). + Title("Enter hostname URL"). + Placeholder("https://your-instance.example.com"). + Value(&hostname), ), ) - if err := form.Run(); err != nil { + if err := hostnameForm.Run(); err != nil { logger.Error("Error: %v", err) - return - } - - // If self-hosted, prompt for hostname - if hostingOption == HostingOptionSelfHosted { - hostnameForm := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Enter hostname URL"). - Placeholder("https://your-instance.example.com"). - Value(&hostname), - ), - ) - - if err := hostnameForm.Run(); err != nil { - logger.Error("Error: %v", err) - return - } - } else { - // For cloud, set the default hostname - hostname = "app.pangolin.net" + return err } + } else { + // For cloud, set the default hostname + hostname = "app.pangolin.net" } + } - // Normalize hostname (preserve protocol, remove trailing slash) - hostname = strings.TrimSuffix(hostname, "/") + // Normalize hostname (preserve protocol, remove trailing slash) + hostname = strings.TrimSuffix(hostname, "/") - // If no protocol specified, default to https - if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") { - hostname = "https://" + hostname - } + // If no protocol specified, default to https + if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") { + hostname = "https://" + hostname + } - // Perform web login - sessionToken, err := loginWithWeb(hostname) - if err != nil { - logger.Error("%v", err) - return - } + // Perform web login + sessionToken, err := loginWithWeb(hostname) + if err != nil { + logger.Error("%v", err) + return err + } - if sessionToken == "" { - logger.Error("Login appeared successful but no session token was received.") - return - } + if sessionToken == "" { + err := errors.New("login appeared successful but no session token was received.") + logger.Error("Error: %v", err) + return err + } - // Update the global API client (always initialized) - // Update base URL and token (hostname already includes protocol) - apiBaseURL := hostname + "/api/v1" - apiClient.SetBaseURL(apiBaseURL) - apiClient.SetToken(sessionToken) + // Update the global API client (always initialized) + // Update base URL and token (hostname already includes protocol) + apiBaseURL := hostname + "/api/v1" + apiClient.SetBaseURL(apiBaseURL) + apiClient.SetToken(sessionToken) - logger.Success("Device authorized") - fmt.Println() + logger.Success("Device authorized") + fmt.Println() - // Get user information - var user *api.User - user, err = apiClient.GetUser() - if err != nil { - logger.Error("Failed to get user information: %v", err) - return // FIXME: handle errors properly with exit codes! - } + // Get user information + var user *api.User + user, err = apiClient.GetUser() + if err != nil { + logger.Error("Failed to get user information: %v", err) + return err + } - if _, exists := accountStore.Accounts[user.UserID]; exists { - logger.Warning("Already logged in as this user; no action needed") - return - } + if _, exists := accountStore.Accounts[user.UserID]; exists { + logger.Warning("Already logged in as this user; no action needed") + return nil + } - // Ensure OLM credentials exist and are valid - userID := user.UserID + // Ensure OLM credentials exist and are valid + userID := user.UserID - orgID, err := utils.SelectOrgForm(apiClient, userID) - if err != nil { - logger.Error("Failed to select organization: %v", err) - return - } + orgID, err := utils.SelectOrgForm(apiClient, userID) + if err != nil { + logger.Error("Failed to select organization: %v", err) + return err + } - newOlmCreds, err := apiClient.CreateOlm(userID, utils.GetDeviceName()) - if err != nil { - logger.Error("Failed to obtain olm credentials: %v", err) - return - } + newOlmCreds, err := apiClient.CreateOlm(userID, utils.GetDeviceName()) + if err != nil { + logger.Error("Failed to obtain olm credentials: %v", err) + return err + } - newAccount := config.Account{ - UserID: userID, - Host: hostname, - Email: user.Email, - SessionToken: sessionToken, - OrgID: orgID, - OlmCredentials: &config.OlmCredentials{ - ID: newOlmCreds.OlmID, - Secret: newOlmCreds.Secret, - }, - } + newAccount := config.Account{ + UserID: userID, + Host: hostname, + Email: user.Email, + SessionToken: sessionToken, + OrgID: orgID, + OlmCredentials: &config.OlmCredentials{ + ID: newOlmCreds.OlmID, + Secret: newOlmCreds.Secret, + }, + } - accountStore.Accounts[user.UserID] = newAccount - accountStore.ActiveUserID = userID + accountStore.Accounts[user.UserID] = newAccount + accountStore.ActiveUserID = userID - err = accountStore.Save() - if err != nil { - logger.Error("Failed to save account store: %s", err) - logger.Warning("You may not be able to login properly until this is saved.") - return - } + err = accountStore.Save() + if err != nil { + logger.Error("Failed to save account store: %s", err) + logger.Warning("You may not be able to login properly until this is saved.") + return err + } - // List and select organization - if user != nil { - if _, err := utils.SelectOrgForm(apiClient, user.UserID); err != nil { - logger.Warning("%v", err) - } + // List and select organization + if user != nil { + if _, err := utils.SelectOrgForm(apiClient, user.UserID); err != nil { + logger.Warning("%v", err) } + } - // Print logged in message after all setup is complete - if user != nil { - displayName := user.Email - if displayName == "" && user.Username != nil && *user.Username != "" { - displayName = *user.Username - } - if displayName != "" { - logger.Success("Logged in as %s", displayName) - } + // Print logged in message after all setup is complete + if user != nil { + displayName := user.Email + if displayName == "" && user.Username != nil && *user.Username != "" { + displayName = *user.Username } - }, + if displayName != "" { + logger.Success("Logged in as %s", displayName) + } + } + + return nil } diff --git a/cmd/auth/logout/logout.go b/cmd/auth/logout/logout.go index 76b4af2..937dbdc 100644 --- a/cmd/auth/logout/logout.go +++ b/cmd/auth/logout/logout.go @@ -1,6 +1,8 @@ package logout import ( + "errors" + "os" "time" "github.com/charmbracelet/huh" @@ -11,105 +13,115 @@ import ( "github.com/spf13/cobra" ) -var LogoutCmd = &cobra.Command{ - Use: "logout", - Short: "Logout from Pangolin", - Long: "Logout and clear your session", - Run: func(cmd *cobra.Command, args []string) { - apiClient := api.FromContext(cmd.Context()) - - // Check if client is running before logout - olmClient := olm.NewClient("") - if olmClient.IsRunning() { - // Check that the client was started by this CLI by verifying the version - status, err := olmClient.GetStatus() - if err != nil { - logger.Warning("Failed to get client status: %v", err) - // Continue with logout even if we can't check version - } else if status.Agent == olm.AgentName { - // Only prompt and stop if client was started by this CLI - // Prompt user to confirm they want to disconnect the client - var confirm bool - confirmForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("A client is currently running. Logging out will disconnect it."). - Description("Do you want to continue?"). - Value(&confirm), - ), - ) - - if err := confirmForm.Run(); err != nil { - logger.Error("Error: %v", err) - return - } +func LogoutCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Logout from Pangolin", + Long: "Logout and clear your session", + Run: func(cmd *cobra.Command, args []string) { + if err := logoutMain(cmd); err != nil { + os.Exit(1) + } + }, + } - if !confirm { - logger.Info("Logout cancelled") - return - } + return cmd +} - // Kill the client without showing TUI - _, err := olmClient.Exit() - if err != nil { - logger.Warning("Failed to send exit signal to client: %v", err) - } else { - // Wait for client to stop (poll until socket is gone) - maxWait := 10 * time.Second - pollInterval := 200 * time.Millisecond - elapsed := time.Duration(0) - for olmClient.IsRunning() && elapsed < maxWait { - time.Sleep(pollInterval) - elapsed += pollInterval - } - if olmClient.IsRunning() { - logger.Warning("Client did not stop within timeout") - } - } - } - // If version doesn't match, skip client shutdown and continue with logout - } +func logoutMain(cmd *cobra.Command) error { + apiClient := api.FromContext(cmd.Context()) - // Check if there's an active session in the key store - accountStore, err := config.LoadAccountStore() + // Check if client is running before logout + olmClient := olm.NewClient("") + if olmClient.IsRunning() { + // Check that the client was started by this CLI by verifying the version + status, err := olmClient.GetStatus() if err != nil { - logger.Error("Failed to load account store: %s", err) - return - } + logger.Warning("Failed to get client status: %v", err) + // Continue with logout even if we can't check version + } else if status.Agent == olm.AgentName { + // Only prompt and stop if client was started by this CLI + // Prompt user to confirm they want to disconnect the client + var confirm bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("A client is currently running. Logging out will disconnect it."). + Description("Do you want to continue?"). + Value(&confirm), + ), + ) + + if err := confirmForm.Run(); err != nil { + logger.Error("Error: %v", err) + return err + } - if accountStore.ActiveUserID == "" { - logger.Success("Already logged out!") - return - } + if !confirm { + err := errors.New("logout cancelled") + logger.Info("%v", err) + return err + } - // Try to logout from server (client is always initialized) - if err := apiClient.Logout(); err != nil { - // Ignore logout errors - we'll still clear local data - logger.Debug("Failed to logout from server: %v", err) + // Kill the client without showing TUI + _, err := olmClient.Exit() + if err != nil { + logger.Warning("Failed to send exit signal to client: %v", err) + } else { + // Wait for client to stop (poll until socket is gone) + maxWait := 10 * time.Second + pollInterval := 200 * time.Millisecond + elapsed := time.Duration(0) + for olmClient.IsRunning() && elapsed < maxWait { + time.Sleep(pollInterval) + elapsed += pollInterval + } + if olmClient.IsRunning() { + logger.Warning("Client did not stop within timeout") + } + } } + // If version doesn't match, skip client shutdown and continue with logout + } - deletedAccount := accountStore.Accounts[accountStore.ActiveUserID] - delete(accountStore.Accounts, accountStore.ActiveUserID) + // Check if there's an active session in the key store + accountStore, err := config.LoadAccountStore() + if err != nil { + logger.Error("Failed to load account store: %s", err) + return err + } - // If there are still other accounts, then we need to set the active key for it. - if nextUserID, ok := anyKey(accountStore.Accounts); ok { - accountStore.ActiveUserID = nextUserID + if accountStore.ActiveUserID == "" { + logger.Success("Already logged out!") + return nil + } - // TODO: perform automatic select of account when required - } else { - accountStore.ActiveUserID = "" - } + // Try to logout from server (client is always initialized) + if err := apiClient.Logout(); err != nil { + // Ignore logout errors - we'll still clear local data + logger.Debug("Failed to logout from server: %v", err) + } - // Automatically set next active user ID to the first account found. + deletedAccount := accountStore.Accounts[accountStore.ActiveUserID] + delete(accountStore.Accounts, accountStore.ActiveUserID) - if err := accountStore.Save(); err != nil { - logger.Error("Failed to save account store: %v", err) - return - } + // If there are still other accounts, then we need to set the active key again. + // Automatically set next active user ID to the first account found. + if nextUserID, ok := anyKey(accountStore.Accounts); ok { + accountStore.ActiveUserID = nextUserID + } else { + accountStore.ActiveUserID = "" + } + + if err := accountStore.Save(); err != nil { + logger.Error("Failed to save account store: %v", err) + return err + } + + // Print logout message with account name + logger.Success("Logged out of Pangolin account %s", deletedAccount.Email) - // Print logout message with account name - logger.Success("Logged out of Pangolin account %s", deletedAccount.Email) - }, + return nil } func anyKey[K comparable, V any](m map[K]V) (K, bool) { diff --git a/cmd/auth/status/status.go b/cmd/auth/status/status.go index f5daac4..84a131b 100644 --- a/cmd/auth/status/status.go +++ b/cmd/auth/status/status.go @@ -2,6 +2,7 @@ package status import ( "fmt" + "os" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" @@ -9,52 +10,64 @@ import ( "github.com/spf13/cobra" ) -var StatusCmd = &cobra.Command{ - Use: "status", - Short: "Check authentication status", - Long: "Check if you are logged in and view your account information", - Run: func(cmd *cobra.Command, args []string) { - apiClient := api.FromContext(cmd.Context()) - accountStore := config.AccountStoreFromContext(cmd.Context()) - - account, err := accountStore.ActiveAccount() - if err != nil { - logger.Info("Status: %s", err) - logger.Info("Run 'pangolin login' to authenticate") - return - } - - // User info exists in config, try to get user from API - user, err := apiClient.GetUser() - if err != nil { - // Unable to get user - consider logged out (previously logged in but now not) - logger.Info("Status: Logged out: %v", err) - logger.Info("Your session has expired or is invalid") - logger.Info("Run 'pangolin login' to authenticate again") - return - } - - // Successfully got user - logged in - logger.Success("Status: Logged in") - // Show hostname if available - logger.Info("@ %s", account.Host) - fmt.Println() - - // Display user information - displayName := user.Email - if user.Username != nil && *user.Username != "" { - displayName = *user.Username - } else if user.Name != nil && *user.Name != "" { - displayName = *user.Name - } - if displayName != "" { - logger.Info("User: %s", displayName) - } - if user.UserID != "" { - logger.Info("User ID: %s", user.UserID) - } - - // Display organization information - logger.Info("Org ID: %s", account.OrgID) - }, +func StatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Check authentication status", + Long: "Check if you are logged in and view your account information", + Run: func(cmd *cobra.Command, args []string) { + if err := statusMain(cmd); err != nil { + os.Exit(1) + } + }, + } + + return cmd +} + +func statusMain(cmd *cobra.Command) error { + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + + account, err := accountStore.ActiveAccount() + if err != nil { + logger.Info("Status: %s", err) + logger.Info("Run 'pangolin login' to authenticate") + return err + } + + // User info exists in config, try to get user from API + user, err := apiClient.GetUser() + if err != nil { + // Unable to get user - consider logged out (previously logged in but now not) + logger.Info("Status: logged out: %v", err) + logger.Info("Your session has expired or is invalid") + logger.Info("Run 'pangolin login' to authenticate again") + return err + } + + // Successfully got user - logged in + logger.Success("Status: logged in") + // Show hostname if available + logger.Info("@ %s", account.Host) + fmt.Println() + + // Display user information + displayName := user.Email + if user.Username != nil && *user.Username != "" { + displayName = *user.Username + } else if user.Name != nil && *user.Name != "" { + displayName = *user.Name + } + if displayName != "" { + logger.Info("User: %s", displayName) + } + if user.UserID != "" { + logger.Info("User ID: %s", user.UserID) + } + + // Display organization information + logger.Info("Org ID: %s", account.OrgID) + + return nil } diff --git a/cmd/down/client.go b/cmd/down/client.go deleted file mode 100644 index 4cc8b04..0000000 --- a/cmd/down/client.go +++ /dev/null @@ -1,81 +0,0 @@ -package down - -import ( - "os" - - "github.com/fosrl/cli/internal/config" - "github.com/fosrl/cli/internal/logger" - "github.com/fosrl/cli/internal/olm" - "github.com/fosrl/cli/internal/tui" - "github.com/spf13/cobra" -) - -var ClientCmd = &cobra.Command{ - Use: "client", - Short: "Stop the client connection", - Long: "Stop the currently running client connection", - Run: func(cmd *cobra.Command, args []string) { - cfg := config.ConfigFromContext(cmd.Context()) - - // Get socket path from config or use default - client := olm.NewClient("") - - // Check if client is running - if !client.IsRunning() { - logger.Info("No client is currently running") - return - } - - // Check that the client was started by this CLI by verifying the version - status, err := client.GetStatus() - if err != nil { - logger.Error("Failed to get client status: %v", err) - os.Exit(1) - } - if status.Agent != olm.AgentName { - logger.Error("Client was not started by Pangolin CLI (version: %s)", status.Version) - logger.Info("Only clients started by this CLI can be stopped using this command") - os.Exit(1) - } - - // Send exit signal - exitResp, err := client.Exit() - if err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } - - // Show log preview until process stops - completed, err := tui.NewLogPreview(tui.LogPreviewConfig{ - LogFile: cfg.LogFile, - Header: "Shutting down client...", - ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { - // Exit when process is no longer running (socket doesn't exist) - if !client.IsRunning() { - return true, true - } - return false, false - }, - StatusFormatter: func(isRunning bool, status *olm.StatusResponse) string { - if !isRunning { - return "Stopped" - } - return "Stopping..." - }, - }) - if err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } - - if completed { - logger.Success("Client shutdown completed") - } else { - logger.Info("Client shutdown initiated: %s", exitResp.Status) - } - }, -} - -func init() { - DownCmd.AddCommand(ClientCmd) -} diff --git a/cmd/down/client/client.go b/cmd/down/client/client.go new file mode 100644 index 0000000..1851f76 --- /dev/null +++ b/cmd/down/client/client.go @@ -0,0 +1,92 @@ +package client + +import ( + "errors" + "os" + + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/olm" + "github.com/fosrl/cli/internal/tui" + "github.com/spf13/cobra" +) + +func ClientDownCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "client", + Short: "Stop the client connection", + Long: "Stop the currently running client connection", + Run: func(cmd *cobra.Command, args []string) { + if err := clientDownMain(cmd); err != nil { + os.Exit(1) + } + }, + } + + return cmd +} + +func clientDownMain(cmd *cobra.Command) error { + cfg := config.ConfigFromContext(cmd.Context()) + + // Get socket path from config or use default + client := olm.NewClient("") + + // Check if client is running + if !client.IsRunning() { + err := errors.New("no client is currently running") + logger.Info("Error: %v", err) + return err + } + + // Check that the client was started by this CLI by verifying the version + status, err := client.GetStatus() + if err != nil { + logger.Error("Failed to get client status: %v", err) + return err + } + + if status.Agent != olm.AgentName { + logger.Error("Client was not started by Pangolin CLI (version: %s)", status.Version) + logger.Info("Only clients started by this CLI can be stopped using this command") + return err + } + + // Send exit signal + exitResp, err := client.Exit() + if err != nil { + logger.Error("Error: %v", err) + return err + } + + // Show log preview until process stops + completed, err := tui.NewLogPreview(tui.LogPreviewConfig{ + LogFile: cfg.LogFile, + Header: "Shutting down client...", + ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { + // Exit when process is no longer running (socket doesn't exist) + if !client.IsRunning() { + return true, true + } + return false, false + }, + StatusFormatter: func(isRunning bool, status *olm.StatusResponse) string { + if !isRunning { + return "Stopped" + } + return "Stopping..." + }, + }) + if err != nil { + logger.Error("Error: %v", err) + return err + } + + if completed { + logger.Success("Client shutdown completed") + } else { + logger.Info("Client shutdown initiated: %s", exitResp.Status) + } + + return nil +} diff --git a/cmd/down/down.go b/cmd/down/down.go index 56c413c..984557d 100644 --- a/cmd/down/down.go +++ b/cmd/down/down.go @@ -1,17 +1,23 @@ package down import ( + "github.com/fosrl/cli/cmd/down/client" "github.com/spf13/cobra" ) -var DownCmd = &cobra.Command{ - Use: "down", - Short: "Stop a client", - Long: "Stop a client connection", - Run: func(cmd *cobra.Command, args []string) { - // Default to client subcommand if no subcommand is provided - // This makes "pangolin down" equivalent to "pangolin down client" - ClientCmd.SetContext(cmd.Context()) - ClientCmd.Run(ClientCmd, args) - }, +func DownCmd() *cobra.Command { + // If no subcommand is specified, run the `client` + // subcommand by default. + cmd := client.ClientDownCmd() + + cmd.Use = "down" + cmd.Short = "Stop a connection" + cmd.Long = `Bring down a connection. + +If ran with no subcommand, 'client' is passed. +` + + cmd.AddCommand(client.ClientDownCmd()) + + return cmd } diff --git a/cmd/logs/client.go b/cmd/logs/client/client.go similarity index 75% rename from cmd/logs/client.go rename to cmd/logs/client/client.go index c48a9f4..db98a97 100644 --- a/cmd/logs/client.go +++ b/cmd/logs/client/client.go @@ -1,4 +1,4 @@ -package logs +package client import ( "bufio" @@ -14,48 +14,60 @@ import ( "github.com/spf13/cobra" ) -var ( - flagFollow bool - flagLines int -) +type ClientLogsCmdOpts struct { + Follow bool + Lines int +} + +func ClientLogsCmd() *cobra.Command { + opts := ClientLogsCmdOpts{} -var clientLogsCmd = &cobra.Command{ - Use: "client", - Short: "View client logs", - Long: "View client logs. Use -f to follow log output.", - Run: func(cmd *cobra.Command, args []string) { - cfg := config.ConfigFromContext(cmd.Context()) - - if flagFollow { - // Follow the log file - if err := watchLogFile(cfg.LogFile, flagLines); err != nil { - logger.Error("Error: %v", err) + cmd := &cobra.Command{ + Use: "client", + Short: "View client logs", + Long: "View client logs. Use -f to follow log output.", + Run: func(cmd *cobra.Command, args []string) { + if err := clientLogsMain(cmd, &opts); err != nil { os.Exit(1) } - } else { - // Just print the current log file contents - if flagLines > 0 { - // Show last N lines - if err := printLastLines(cfg.LogFile, flagLines); err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } - } else { - // Show all lines - if err := printLogFile(cfg.LogFile); err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } - } - } - }, + }, + } + + cmd.Flags().BoolVarP(&opts.Follow, "follow", "f", false, "Follow log output (like tail -f)") + cmd.Flags().IntVarP(&opts.Lines, "lines", "n", 0, "Number of lines to show (0 = all lines, only used with -f to show lines before following)") + + return cmd } -func init() { - clientLogsCmd.Flags().BoolVarP(&flagFollow, "follow", "f", false, "Follow log output (like tail -f)") - clientLogsCmd.Flags().IntVarP(&flagLines, "lines", "n", 0, "Number of lines to show (0 = all lines, only used with -f to show lines before following)") +func clientLogsMain(cmd *cobra.Command, opts *ClientLogsCmdOpts) error { + cfg := config.ConfigFromContext(cmd.Context()) - LogsCmd.AddCommand(clientLogsCmd) + if opts.Follow { + // Follow the log file + if err := watchLogFile(cfg.LogFile, opts.Lines); err != nil { + logger.Error("Error: %v", err) + return err + } + + return nil + } + + // Just print the current log file contents + if opts.Lines > 0 { + // Show last N lines + if err := printLastLines(cfg.LogFile, opts.Lines); err != nil { + logger.Error("Error: %v", err) + return err + } + } else { + // Show all lines + if err := printLogFile(cfg.LogFile); err != nil { + logger.Error("Error: %v", err) + return err + } + } + + return nil } // printLogFile prints the contents of the log file diff --git a/cmd/logs/logs.go b/cmd/logs/logs.go index 34e26fb..5777188 100644 --- a/cmd/logs/logs.go +++ b/cmd/logs/logs.go @@ -1,12 +1,18 @@ package logs import ( + "github.com/fosrl/cli/cmd/logs/client" "github.com/spf13/cobra" ) -var LogsCmd = &cobra.Command{ - Use: "logs", - Short: "View client logs", - Long: "View and follow client logs", -} +func LogsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs", + Short: "View client logs", + Long: "View and follow client logs", + } + + cmd.AddCommand(client.ClientLogsCmd()) + return cmd +} diff --git a/cmd/root.go b/cmd/root.go index b24d6a6..31f534b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,24 +31,25 @@ import ( // state when doing doc generation. func RootCommand(initResources bool) (*cobra.Command, error) { cmd := &cobra.Command{ - Use: "pangolin", - Short: "Pangolin CLI", + Use: "pangolin", + Short: "Pangolin CLI", + SilenceUsage: true, CompletionOptions: cobra.CompletionOptions{ HiddenDefaultCmd: true, }, PersistentPreRunE: mainCommandPreRun, } - cmd.AddCommand(auth.AuthCmd) - cmd.AddCommand(selectcmd.SelectCmd) - cmd.AddCommand(up.UpCmd) - cmd.AddCommand(down.DownCmd) - cmd.AddCommand(logs.LogsCmd) - cmd.AddCommand(status.StatusCmd) - cmd.AddCommand(update.UpdateCmd) - cmd.AddCommand(version.VersionCmd) - cmd.AddCommand(login.LoginCmd) - cmd.AddCommand(logout.LogoutCmd) + cmd.AddCommand(auth.AuthCommand()) + cmd.AddCommand(selectcmd.SelectCmd()) + cmd.AddCommand(up.UpCmd()) + cmd.AddCommand(down.DownCmd()) + cmd.AddCommand(logs.LogsCmd()) + cmd.AddCommand(status.StatusCmd()) + cmd.AddCommand(update.UpdateCmd()) + cmd.AddCommand(version.VersionCmd()) + cmd.AddCommand(login.LoginCmd()) + cmd.AddCommand(logout.LogoutCmd()) if !initResources { return cmd, nil diff --git a/cmd/select/account/account.go b/cmd/select/account/account.go index b2c32f7..e4e2bb4 100644 --- a/cmd/select/account/account.go +++ b/cmd/select/account/account.go @@ -1,7 +1,9 @@ package account import ( + "errors" "fmt" + "os" "github.com/charmbracelet/huh" "github.com/fosrl/cli/internal/config" @@ -10,82 +12,101 @@ import ( "github.com/spf13/cobra" ) -var ( - accountToSelect string - hostToSelect string -) +type AccountCmdOpts struct { + Account string + Host string +} -var AccountCmd = &cobra.Command{ - Use: "account", - Short: "Select an account", - Long: "List your logged-in accounts and select active one", - Run: func(cmd *cobra.Command, args []string) { - accountStore := config.AccountStoreFromContext(cmd.Context()) +func AccountCmd() *cobra.Command { + opts := AccountCmdOpts{} - if len(accountStore.Accounts) == 0 { - logger.Warning("Not logged in.") - return - } + cmd := &cobra.Command{ + Use: "account", + Short: "Select an account", + Long: "List your logged-in accounts and select active one", + Run: func(cmd *cobra.Command, args []string) { + if err := accountMain(cmd, &opts); err != nil { + os.Exit(1) + } + }, + } - var selectedAccount *config.Account + cmd.Flags().StringVarP(&opts.Account, "account", "a", "", "Account to select") + cmd.Flags().StringVar(&opts.Host, "host", "", "Pangolin host where account is located") - // If flag is provided, find an account that matches the - // terms verbatim. - if accountToSelect != "" { - for _, account := range accountStore.Accounts { - if hostToSelect != "" && hostToSelect != account.Host { - continue - } + return cmd +} - if accountToSelect == account.Email { - selectedAccount = &account - break - } - } +func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) error { + accountStore := config.AccountStoreFromContext(cmd.Context()) - if selectedAccount == nil { - logger.Error("No accounts found that match the search terms") - return - } - } else { - // No flag provided, use GUI selection if necessary - selected, err := selectAccountForm(accountStore.Accounts) - if err != nil { - logger.Error("Failed to select account: %v", err) - return + if len(accountStore.Accounts) == 0 { + err := errors.New("not logged in") + logger.Error("Error: %v", err) + return err + } + + var selectedAccount *config.Account + + // If flag is provided, find an account that matches the + // terms verbatim. + if opts.Account != "" { + for _, account := range accountStore.Accounts { + if opts.Host != "" && opts.Host != account.Host { + continue } - selectedAccount = selected + if opts.Account == account.Email { + selectedAccount = &account + break + } } - accountStore.ActiveUserID = selectedAccount.UserID - if err := accountStore.Save(); err != nil { - logger.Error("Failed to save account to store: %v", err) - return + if selectedAccount == nil { + err := errors.New("no accounts found that match the search terms") + logger.Error("Error: %v", err) + return err + } + } else { + // No flag provided, use GUI selection if necessary + selected, err := selectAccountForm(accountStore.Accounts, opts.Host) + if err != nil { + logger.Error("Error: failed to select account: %v", err) + return err } - // Check if olmClient is running and if we need to shut it down - olmClient := olm.NewClient("") - if olmClient.IsRunning() { - logger.Info("Shutting down running client") - _, err := olmClient.Exit() - if err != nil { - logger.Warning("Failed to shut down OLM client: %s; you may need to do so manually.", err) - } + selectedAccount = selected + } + + accountStore.ActiveUserID = selectedAccount.UserID + if err := accountStore.Save(); err != nil { + logger.Error("Error: failed to save account to store: %v", err) + return err + } + + // Check if olmClient is running and if we need to shut it down + olmClient := olm.NewClient("") + if olmClient.IsRunning() { + logger.Info("Shutting down running client") + _, err := olmClient.Exit() + if err != nil { + logger.Warning("Failed to shut down OLM client: %s; you may need to do so manually.", err) } + } + + selectedAccountStr := fmt.Sprintf("%s @ %s", selectedAccount.Email, selectedAccount.Host) + logger.Success("Successfully selected account: %s", selectedAccountStr) - selectedAccountStr := fmt.Sprintf("%s @ %s", selectedAccount.Email, selectedAccount.Host) - logger.Success("Successfully selected account: %s", selectedAccountStr) - }, + return nil } // selectAccountForm lists organizations for a user and prompts them to select one. // It returns the selected org ID and any error. // If the user has only one organization, it's automatically selected. -func selectAccountForm(accounts map[string]config.Account) (*config.Account, error) { +func selectAccountForm(accounts map[string]config.Account, hostFilter string) (*config.Account, error) { var filteredAccounts []*config.Account for _, account := range accounts { - if hostToSelect == "" || hostToSelect == account.Host { + if hostFilter == "" || hostFilter == account.Host { filteredAccounts = append(filteredAccounts, &account) } } @@ -131,8 +152,3 @@ func selectAccountForm(accounts map[string]config.Account) (*config.Account, err return selectedAccountOption.Account, nil } - -func init() { - AccountCmd.Flags().StringVarP(&accountToSelect, "account", "a", "", "Account to select") - AccountCmd.Flags().StringVar(&hostToSelect, "host", "", "Pangolin host where account is located") -} diff --git a/cmd/select/org/org.go b/cmd/select/org/org.go index 2cc0d1b..f3943b4 100644 --- a/cmd/select/org/org.go +++ b/cmd/select/org/org.go @@ -2,6 +2,7 @@ package org import ( "fmt" + "os" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" @@ -12,87 +13,106 @@ import ( "github.com/spf13/cobra" ) -var flagOrgID string +type OrgCmdOpts struct { + OrgID string +} -var OrgCmd = &cobra.Command{ - Use: "org", - Short: "Select an organization", - Long: "List your organizations and select one to use", - Run: func(cmd *cobra.Command, args []string) { - apiClient := api.FromContext(cmd.Context()) - accountStore := config.AccountStoreFromContext(cmd.Context()) - cfg := config.ConfigFromContext(cmd.Context()) +func OrgCmd() *cobra.Command { + opts := OrgCmdOpts{} - activeAccount, err := accountStore.ActiveAccount() - if err != nil { - logger.Error("%v", err) - return + cmd := &cobra.Command{ + Use: "org", + Short: "Select an organization", + Long: "List your organizations and select one to use", + Run: func(cmd *cobra.Command, args []string) { + if err := orgMain(cmd, &opts); err != nil { + os.Exit(1) + } + }, + } - } - userID := activeAccount.UserID + cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization `ID` to select") - var selectedOrgID string + return cmd +} - // Check if --org-id flag is provided - if flagOrgID != "" { - // Validate that the org exists - orgsResp, err := apiClient.ListUserOrgs(userID) - if err != nil { - logger.Error("Failed to list organizations: %v", err) - return - } +func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) error { + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + cfg := config.ConfigFromContext(cmd.Context()) - // Check if the provided orgId exists in the user's organizations - orgExists := false - for _, org := range orgsResp.Orgs { - if org.OrgID == flagOrgID { - orgExists = true - break - } - } + activeAccount, err := accountStore.ActiveAccount() + if err != nil { + logger.Error("%v", err) + return err - if !orgExists { - logger.Error("Organization '%s' not found or you don't have access to it", flagOrgID) - return - } + } + userID := activeAccount.UserID - // Org exists, use it - selectedOrgID = flagOrgID - } else { - // No flag provided, use GUI selection - selectedOrgID, err = utils.SelectOrgForm(apiClient, userID) - if err != nil { - logger.Error("%v", err) - return + var selectedOrgID string + + // Check if --org-id flag is provided + if opts.OrgID != "" { + // Validate that the org exists + orgsResp, err := apiClient.ListUserOrgs(userID) + if err != nil { + logger.Error("Failed to list organizations: %v", err) + return err + } + + // Check if the provided orgId exists in the user's organizations + orgExists := false + for _, org := range orgsResp.Orgs { + if org.OrgID == opts.OrgID { + orgExists = true + break } } - activeAccount.OrgID = selectedOrgID - if err := accountStore.Save(); err != nil { - logger.Error("Failed to save account to store: %v", err) - return + if !orgExists { + err := fmt.Errorf("organization '%s' not found or you don't have access to it", opts.OrgID) + logger.Error("Error: %v", err) + return err } - // Switch active client if running - utils.SwitchActiveClientOrg(selectedOrgID) - - // Check if olmClient is running and if we need to monitor a switch - olmClient := olm.NewClient("") - if olmClient.IsRunning() { - // Get current status - if it doesn't match the new org, monitor the switch - currentStatus, err := olmClient.GetStatus() - if err == nil && currentStatus != nil && currentStatus.OrgID != selectedOrgID { - // Switch was sent, monitor the switch process - monitorOrgSwitch(cfg.LogFile, selectedOrgID) - } else { - // Already on the correct org or no status available - logger.Success("Successfully selected organization: %s", selectedOrgID) - } + // Org exists, use it + selectedOrgID = opts.OrgID + } else { + // No flag provided, use GUI selection + selectedOrgID, err = utils.SelectOrgForm(apiClient, userID) + if err != nil { + logger.Error("%v", err) + return err + } + } + + activeAccount.OrgID = selectedOrgID + if err := accountStore.Save(); err != nil { + logger.Error("Failed to save account to store: %v", err) + return err + } + + // Switch active client if running + utils.SwitchActiveClientOrg(selectedOrgID) + + // Check if olmClient is running and if we need to monitor a switch + olmClient := olm.NewClient("") + if olmClient.IsRunning() { + // Get current status - if it doesn't match the new org, monitor the switch + currentStatus, err := olmClient.GetStatus() + if err == nil && currentStatus != nil && currentStatus.OrgID != selectedOrgID { + // Switch was sent, monitor the switch process + monitorOrgSwitch(cfg.LogFile, selectedOrgID) } else { - // Client not running, no switch needed + // Already on the correct org or no status available logger.Success("Successfully selected organization: %s", selectedOrgID) } - }, + } else { + // Client not running, no switch needed + logger.Success("Successfully selected organization: %s", selectedOrgID) + } + + return nil } // monitorOrgSwitch monitors the organization switch process with log preview @@ -131,7 +151,3 @@ func monitorOrgSwitch(logFile string, orgID string) { logger.Warning("Failed to monitor organization switch: %v", err) } } - -func init() { - OrgCmd.Flags().StringVar(&flagOrgID, "org", "", "Organization ID to select") -} diff --git a/cmd/select/select.go b/cmd/select/select.go index 43b2957..f2a21d4 100644 --- a/cmd/select/select.go +++ b/cmd/select/select.go @@ -6,13 +6,15 @@ import ( "github.com/spf13/cobra" ) -var SelectCmd = &cobra.Command{ - Use: "select", - Short: "Select objects to work with", - Long: "Select objects to work with", -} +func SelectCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "select", + Short: "Select account information to use", + Long: "Select account information to use", + } + + cmd.AddCommand(account.AccountCmd()) + cmd.AddCommand(org.OrgCmd()) -func init() { - SelectCmd.AddCommand(account.AccountCmd) - SelectCmd.AddCommand(org.OrgCmd) + return cmd } diff --git a/cmd/status/client.go b/cmd/status/client/client.go similarity index 66% rename from cmd/status/client.go rename to cmd/status/client/client.go index 7aa1f4e..e6ddb93 100644 --- a/cmd/status/client.go +++ b/cmd/status/client/client.go @@ -1,4 +1,4 @@ -package status +package client import ( "encoding/json" @@ -12,55 +12,65 @@ import ( "github.com/spf13/cobra" ) -var flagJSON bool - -var ClientCmd = &cobra.Command{ - Use: "client", - Short: "Show client status", - Long: "Display current client connection status and peer information", - Run: func(cmd *cobra.Command, args []string) { - // Get socket path from config or use default - client := olm.NewClient("") +type ClientStatusCmdOpts = struct { + JSON bool +} - // Check if client is running - if !client.IsRunning() { - logger.Info("No client is currently running") - return - } +func ClientStatusCmd() *cobra.Command { + opts := ClientStatusCmdOpts{} + + cmd := &cobra.Command{ + Use: "client", + Short: "Show client status", + Long: "Display current client connection status and peer information", + Run: func(cmd *cobra.Command, args []string) { + if err := clientStatusMain(&opts); err != nil { + os.Exit(1) + } + }, + } - // Get status - status, err := client.GetStatus() - if err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } + cmd.Flags().BoolVar(&opts.JSON, "json", false, "Print raw JSON response") - // Print raw JSON if flag is set, otherwise print formatted table - if flagJSON { - printJSON(status) - } else { - printStatusTable(status) - } - }, + return cmd } -// addStatusClientFlags adds all status client flags to the given command -func addStatusClientFlags(cmd *cobra.Command) { - cmd.Flags().BoolVar(&flagJSON, "json", false, "Print raw JSON response") -} +func clientStatusMain(opts *ClientStatusCmdOpts) error { + // Get socket path from config or use default + client := olm.NewClient("") + + // Check if client is running + if !client.IsRunning() { + logger.Info("No client is currently running") + return nil + } + + // Get status + status, err := client.GetStatus() + if err != nil { + logger.Error("Error: %v", err) + return err + } + + // Print raw JSON if flag is set, otherwise print formatted table + if opts.JSON { + return printJSON(status) + } else { + printStatusTable(status) + } -func init() { - addStatusClientFlags(ClientCmd) + return nil } // printJSON prints the status response as JSON -func printJSON(status *olm.StatusResponse) { +func printJSON(status *olm.StatusResponse) error { jsonData, err := json.MarshalIndent(status, "", " ") if err != nil { logger.Error("Error marshaling JSON: %v", err) - os.Exit(1) + return err } fmt.Println(string(jsonData)) + return nil } // printStatusTable prints the status information in a table format diff --git a/cmd/status/status.go b/cmd/status/status.go index 666b50e..12939e8 100644 --- a/cmd/status/status.go +++ b/cmd/status/status.go @@ -1,27 +1,23 @@ package status import ( + "github.com/fosrl/cli/cmd/status/client" "github.com/spf13/cobra" - "github.com/spf13/pflag" ) -var StatusCmd = &cobra.Command{ - Use: "status", - Short: "Status commands", - Long: "View status information.", - Run: func(cmd *cobra.Command, args []string) { - // Default to client subcommand if no subcommand is provided - // This makes "pangolin status" equivalent to "pangolin status client" - cmd.Flags().VisitAll(func(flag *pflag.Flag) { - if cmd.Flags().Changed(flag.Name) { - ClientCmd.Flags().Set(flag.Name, flag.Value.String()) - } - }) - ClientCmd.Run(ClientCmd, args) - }, -} +func StatusCmd() *cobra.Command { + // If no subcommand is specified, run the `client` + // subcommand by default. + cmd := client.ClientStatusCmd() + + cmd.Use = "status" + cmd.Short = "Status commands" + cmd.Long = `View status information. + +If ran with no subcommand, 'client' is passed. +` + + cmd.AddCommand(client.ClientStatusCmd()) -func init() { - addStatusClientFlags(StatusCmd) - StatusCmd.AddCommand(ClientCmd) + return cmd } diff --git a/cmd/up/client.go b/cmd/up/client.go deleted file mode 100644 index dbb03a0..0000000 --- a/cmd/up/client.go +++ /dev/null @@ -1,641 +0,0 @@ -package up - -import ( - "context" - "fmt" - "log" - "os" - "os/exec" - "os/signal" - "path/filepath" - "runtime" - "strings" - "syscall" - "time" - - "github.com/fosrl/cli/internal/api" - "github.com/fosrl/cli/internal/config" - "github.com/fosrl/cli/internal/logger" - "github.com/fosrl/cli/internal/olm" - "github.com/fosrl/cli/internal/tui" - "github.com/fosrl/cli/internal/utils" - versionpkg "github.com/fosrl/cli/internal/version" - newtLogger "github.com/fosrl/newt/logger" - olmpkg "github.com/fosrl/olm/olm" - "github.com/spf13/cobra" -) - -const ( - defaultMTU = 1280 - defaultDNS = "8.8.8.8" - defaultInterfaceName = "pangolin" - defaultLogLevel = "info" - defaultEnableAPI = true - defaultSocketPath = "/var/run/olm.sock" - defaultPingInterval = "5s" - defaultPingTimeout = "5s" - defaultHolepunch = true - defaultAgent = "Pangolin CLI" - defaultOverrideDNS = true - defaultTunnelDNS = false -) - -var ( - flagID string - flagSecret string - flagEndpoint string - flagOrgID string - flagMTU int - flagDNS string - flagInterfaceName string - flagLogLevel string - flagHTTPAddr string - flagPingInterval string - flagPingTimeout string - flagHolepunch bool - flagTlsClientCert string - flagAttached bool - flagSilent bool - flagOverrideDNS bool - flagTunnelDNS bool - flagUpstreamDNS []string -) - -var ClientCmd = &cobra.Command{ - Use: "client", - Short: "Start a client connection", - Long: "Bring up a client tunneled connection", - Run: func(cmd *cobra.Command, args []string) { - apiClient := api.FromContext(cmd.Context()) - accountStore := config.AccountStoreFromContext(cmd.Context()) - cfg := config.ConfigFromContext(cmd.Context()) - - if runtime.GOOS == "windows" { - logger.Error("Windows is not supported") - os.Exit(1) - } - - // Check if a client is already running - olmClient := olm.NewClient("") - if olmClient.IsRunning() { - logger.Info("A client is already running") - os.Exit(1) - } - - var olmID, olmSecret string - var credentialsFromKeyring bool - - if flagID != "" && flagSecret != "" { - // Use provided flags - no user session needed, continue even if not logged in - // Org cannot be set when passing id and secret directly - olmID = flagID - olmSecret = flagSecret - credentialsFromKeyring = false - } else if flagID != "" || flagSecret != "" { - // If only one flag is provided, require both - logger.Error("Both --id and --secret must be provided together") - os.Exit(1) - } else { - activeAccount, err := accountStore.ActiveAccount() - if err != nil { - logger.Error("Error: %v. Run `pangolin login` to login", err) - os.Exit(1) - } - - // Ensure OLM credentials exist and are valid - newCredsGenerated, err := utils.EnsureOlmCredentials(apiClient, activeAccount) - if err != nil { - logger.Error("Failed to ensure OLM credentials: %v", err) - os.Exit(1) - } - - if newCredsGenerated { - err := accountStore.Save() - if err != nil { - logger.Error("Failed to save accounts to store: %v", err) - os.Exit(1) - } - } - - olmID = activeAccount.OlmCredentials.ID - olmSecret = activeAccount.OlmCredentials.Secret - - if err != nil { - logger.Error("Failed to get OLM credentials: %v", err) - os.Exit(1) - } - credentialsFromKeyring = true - } - - orgID := flagOrgID - - // Get orgId from flag or viper (required for OLM config when using logged-in user) - if credentialsFromKeyring { - activeAccount, _ := accountStore.ActiveAccount() - - // When using credentials from keyring, orgID is required - if orgID == "" { - orgID = activeAccount.OrgID - } - - if orgID == "" { - logger.Error("Please select an organization first. Run `pangolin select org` to select an organization or pass --org [id] to the command") - os.Exit(1) - } - - if err := utils.EnsureOrgAccess(apiClient, activeAccount); err != nil { - logger.Error("%v", err) - os.Exit(1) - } - } - - // Handle log file setup - if detached mode, always use log file - var logFile string - if !flagAttached { - logFile = cfg.LogFile - } - - endpoint := flagEndpoint - if endpoint == "" { - activeAccount, _ := accountStore.ActiveAccount() - if activeAccount != nil { - endpoint = activeAccount.Host - } - } - - // Handle detached mode - subprocess self without --attach flag - // Skip detached mode if already running as root (we're a subprocess spawned by sudo) - isRunningAsRoot := runtime.GOOS != "windows" && os.Geteuid() == 0 - if !flagAttached && !isRunningAsRoot { - executable, err := os.Executable() - if err != nil { - logger.Error("Error: failed to get executable path: %v", err) - os.Exit(1) - } - - // Build command arguments, excluding --attach flag - cmdArgs := []string{"up", "client"} - - // Add org flag (required for subprocess, which runs as root and won't have user's config) - cmdArgs = append(cmdArgs, "--org", orgID) - - // Add all flags that were set (except --attach) - // OLM credentials are always included (from flags, config, or newly created) - cmdArgs = append(cmdArgs, "--id", olmID) - cmdArgs = append(cmdArgs, "--secret", olmSecret) - - // Always pass endpoint to subprocess (required, subprocess won't have user's config) - // Get endpoint from flag or hostname config (same logic as attached mode) - if endpoint == "" { - logger.Error("Endpoint is required. Please login with a host or provide --endpoint flag") - os.Exit(1) - } - cmdArgs = append(cmdArgs, "--endpoint", endpoint) - - // Optional flags - only include if they were explicitly set - if cmd.Flags().Changed("mtu") { - cmdArgs = append(cmdArgs, "--mtu", fmt.Sprintf("%d", flagMTU)) - } - if cmd.Flags().Changed("netstack-dns") { - cmdArgs = append(cmdArgs, "--netstack-dns", flagDNS) - } - if cmd.Flags().Changed("interface-name") { - cmdArgs = append(cmdArgs, "--interface-name", flagInterfaceName) - } - if cmd.Flags().Changed("log-level") { - cmdArgs = append(cmdArgs, "--log-level", flagLogLevel) - } - if cmd.Flags().Changed("http-addr") { - cmdArgs = append(cmdArgs, "--http-addr", flagHTTPAddr) - } - if cmd.Flags().Changed("ping-interval") { - cmdArgs = append(cmdArgs, "--ping-interval", flagPingInterval) - } - if cmd.Flags().Changed("ping-timeout") { - cmdArgs = append(cmdArgs, "--ping-timeout", flagPingTimeout) - } - if cmd.Flags().Changed("holepunch") { - if flagHolepunch { - cmdArgs = append(cmdArgs, "--holepunch") - } else { - cmdArgs = append(cmdArgs, "--holepunch=false") - } - } - if cmd.Flags().Changed("tls-client-cert") { - cmdArgs = append(cmdArgs, "--tls-client-cert", flagTlsClientCert) - } - if cmd.Flags().Changed("override-dns") { - if flagOverrideDNS { - cmdArgs = append(cmdArgs, "--override-dns") - } else { - cmdArgs = append(cmdArgs, "--override-dns=false") - } - } - if cmd.Flags().Changed("tunnel-dns") { - if flagOverrideDNS { - cmdArgs = append(cmdArgs, "--tunnel-dns") - } else { - cmdArgs = append(cmdArgs, "--tunnel-dns=false") - } - } - if cmd.Flags().Changed("upstream-dns") { - // Comma sep - cmdArgs = append(cmdArgs, "--upstream-dns", strings.Join(flagUpstreamDNS, ",")) - } - - // Add positional args if any - cmdArgs = append(cmdArgs, args...) - - // Create command - subprocess should run with elevated permissions - var procCmd *exec.Cmd - if runtime.GOOS != "windows" { - // Use sudo with a shell wrapper to background the subprocess - // This allows sudo to exit immediately after starting the subprocess - // The subprocess needs root access for network interface creation - // Build shell command with proper quoting using printf %q - var shellArgs []string - shellArgs = append(shellArgs, executable) - shellArgs = append(shellArgs, cmdArgs...) - // Export environment variable to indicate credentials came from config - // This allows subprocess to distinguish between user-provided credentials and stored credentials - shellCmd := "" - if credentialsFromKeyring { - shellCmd = "export PANGOLIN_CREDENTIALS_FROM_KEYRING=1 && " - } - // Build command: nohup executable args >/dev/null 2>&1 & - shellCmd += "nohup" - for _, arg := range shellArgs { - shellCmd += " " + fmt.Sprintf("%q", arg) - } - shellCmd += " >/dev/null 2>&1 &" - procCmd = exec.Command("sudo", "sh", "-c", shellCmd) - // Connect stdin/stderr so sudo can prompt for password interactively - procCmd.Stdin = os.Stdin - procCmd.Stdout = nil - procCmd.Stderr = os.Stderr - } else { - logger.Error("Windows is not supported for detached mode") - os.Exit(1) - } - - // Start the process - if err := procCmd.Start(); err != nil { - logger.Error("Error: failed to start detached process: %v", err) - os.Exit(1) - } - - // Wait for sudo to complete (password prompt + subprocess start) - // The shell wrapper backgrounds the subprocess, so sudo exits immediately - if err := procCmd.Wait(); err != nil { - logger.Error("Error: failed to start subprocess: %v", err) - os.Exit(1) - } - - // In silent mode, skip TUI and just exit after starting the process - if flagSilent { - os.Exit(0) - } - - // Show live log preview and status - completed, err := tui.NewLogPreview(tui.LogPreviewConfig{ - LogFile: logFile, - Header: "Starting up client...", - ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { - // Exit when interface is registered - if status != nil && status.Registered { - return true, true - } - return false, false - }, - OnEarlyExit: func(client *olm.Client) { - // Kill the subprocess if user exits early - if client.IsRunning() { - client.Exit() - } - }, - StatusFormatter: func(isRunning bool, status *olm.StatusResponse) string { - if !isRunning || status == nil { - return "Starting" - } else if status.Registered { - return "Registered" - } - return "Starting" - }, - }) - if err != nil { - logger.Error("Error: %v", err) - os.Exit(1) - } - - // Check if the process completed successfully or was killed - if !completed { - // User exited early - subprocess was killed - logger.Info("Client process killed") - } else { - // Completed successfully - logger.Success("Client interface created successfully") - } - os.Exit(0) - } - - // Helper function to get value with precedence: CLI flag > default - getString := func(flagValue, flagName, configKey, defaultValue string) string { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue - } - - getInt := func(flagValue int, flagName, configKey string, defaultValue int) int { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue - } - - getBool := func(flagValue bool, flagName, configKey string, defaultValue bool) bool { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue - } - - getStringSlice := func(flagValue []string, flagName, configKey string, defaultValue []string) []string { - // Check if flag was explicitly set (CLI takes precedence) - if cmd.Flags().Changed(flagName) { - return flagValue - } - return defaultValue - } - - // Parse duration strings to time.Duration - parseDuration := func(durationStr string, defaultDuration time.Duration) time.Duration { - if durationStr == "" { - return defaultDuration - } - d, err := time.ParseDuration(durationStr) - if err != nil { - logger.Warning("Invalid duration format '%s', using default: %v", durationStr, defaultDuration) - return defaultDuration - } - return d - } - - if endpoint == "" { - logger.Error("Endpoint is required. Please provide --endpoint flag or set hostname in config") - os.Exit(1) - } - - mtu := getInt(flagMTU, "mtu", "mtu", defaultMTU) - dns := getString(flagDNS, "netstack-dns", "netstack-dns", defaultDNS) - interfaceName := getString(flagInterfaceName, "interface-name", "interface_name", defaultInterfaceName) - logLevel := getString(flagLogLevel, "log-level", "log_level", defaultLogLevel) - enableAPI := defaultEnableAPI - - // In detached mode, API cannot be disabled (required for status/control) - if !flagAttached && !enableAPI { - enableAPI = true - } - - httpAddr := getString(flagHTTPAddr, "http-addr", "http_addr", "") - socketPath := defaultSocketPath - pingInterval := getString(flagPingInterval, "ping-interval", "ping_interval", defaultPingInterval) - pingTimeout := getString(flagPingTimeout, "ping-timeout", "ping_timeout", defaultPingTimeout) - holepunch := getBool(flagHolepunch, "holepunch", "holepunch", defaultHolepunch) - tlsClientCert := getString(flagTlsClientCert, "tls-client-cert", "tls_client_cert", "") - version := versionpkg.Version - overrideDNS := getBool(flagOverrideDNS, "override-dns", "override_dns", defaultOverrideDNS) - tunnelDNS := getBool(flagTunnelDNS, "tunnel-dns", "tunnel_dns", defaultTunnelDNS) - upstreamDNS := getStringSlice(flagUpstreamDNS, "upstream-dns", "upstream_dns", []string{defaultDNS}) - - processedUpstreamDNS := make([]string, 0, len(upstreamDNS)) - for _, entry := range upstreamDNS { - entry = strings.TrimSpace(entry) - if entry == "" { - continue - } - - if !strings.Contains(entry, ":") { - entry = entry + ":53" - } - processedUpstreamDNS = append(processedUpstreamDNS, entry) - } - - // If no DNS servers were provided, use default - if len(processedUpstreamDNS) == 0 { - processedUpstreamDNS = []string{defaultDNS + ":53"} - } - - // Parse durations - defaultPingIntervalDuration, _ := time.ParseDuration(defaultPingInterval) - defaultPingTimeoutDuration, _ := time.ParseDuration(defaultPingTimeout) - pingIntervalDuration := parseDuration(pingInterval, defaultPingIntervalDuration) - pingTimeoutDuration := parseDuration(pingTimeout, defaultPingTimeoutDuration) - - // Setup log file if specified - if logFile != "" { - if err := setupLogFile(cfg.LogFile); err != nil { - logger.Error("Error: failed to setup log file: %v", err) - os.Exit(1) - } - } - - // Get UserToken from config if credentials came from config - // Check environment variable to distinguish between: - // - Parent process passing id/secret from config (should fetch userToken) - // - User directly passing id/secret (should NOT fetch userToken) - var userToken string - credentialsFromKeyringEnv := os.Getenv("PANGOLIN_CREDENTIALS_FROM_KEYRING") - if credentialsFromKeyringEnv == "1" || credentialsFromKeyring { - // Credentials came from config, fetch userToken from secrets - activeAccount, err := accountStore.ActiveAccount() - if err != nil { - logger.Error("Failed to get session token: %v", err) - return - } - - userToken = activeAccount.SessionToken - } - - // Create context for signal handling and cleanup - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer olmpkg.Close() - defer stop() - - // Create OLM GlobalConfig with hardcoded values from Swift - olmInitConfig := olmpkg.GlobalConfig{ - LogLevel: logLevel, - EnableAPI: enableAPI, - SocketPath: socketPath, - HTTPAddr: httpAddr, - Version: version, - Agent: defaultAgent, - OnTerminated: func() { - logger.Info("Client process terminated") - stop() - os.Exit(0) - }, - OnAuthError: func(statusCode int, message string) { - logger.Error("Authentication error: %d %s", statusCode, message) - stop() - os.Exit(1) - }, - OnExit: func() { - logger.Info("Client process exiting") - os.Exit(0) - }, - } - - olmConfig := olmpkg.TunnelConfig{ - Endpoint: endpoint, - ID: olmID, - Secret: olmSecret, - OrgID: orgID, - MTU: mtu, - DNS: dns, - InterfaceName: interfaceName, - Holepunch: holepunch, - TlsClientCert: tlsClientCert, - PingIntervalDuration: pingIntervalDuration, - PingTimeoutDuration: pingTimeoutDuration, - OverrideDNS: overrideDNS, - TunnelDNS: tunnelDNS, - UpstreamDNS: processedUpstreamDNS, - } - - // Add UserToken if we have it (from flag or config) - if userToken != "" { - olmConfig.UserToken = userToken - } - - // Check if running with elevated permissions (required for network interface creation) - // This check is only for attached mode; in detached mode, the subprocess runs elevated - if runtime.GOOS != "windows" { - if os.Geteuid() != 0 { - logger.Error("This command requires elevated permissions for network interface creation.") - logger.Info("Please run with sudo or use detached mode (default) to run the subprocess elevated.") - os.Exit(1) - } - } - - olmpkg.Init(ctx, olmInitConfig) - if enableAPI { - olmpkg.StartApi() - } - olmpkg.StartTunnel(olmConfig) - }, -} - -// addClientFlags adds all client flags to the given command -func addClientFlags(cmd *cobra.Command) { - // Optional flags - if not provided, will use config or create new OLM - cmd.Flags().StringVar(&flagID, "id", "", "Client ID (optional, will use user info if not provided)") - cmd.Flags().StringVar(&flagSecret, "secret", "", "Client secret (optional, will use user info if not provided)") - - // Optional flags - cmd.Flags().StringVar(&flagOrgID, "org", "", "Organization ID (optional, will use selected org if not provided)") - cmd.Flags().StringVar(&flagEndpoint, "endpoint", "", "Client endpoint (required if not logged in)") - cmd.Flags().IntVar(&flagMTU, "mtu", 0, fmt.Sprintf("MTU (default: %d)", defaultMTU)) - cmd.Flags().StringVar(&flagDNS, "netstack-dns", "", fmt.Sprintf("DNS server to use for Netstack (default: %s)", defaultDNS)) - cmd.Flags().StringVar(&flagInterfaceName, "interface-name", "", fmt.Sprintf("Interface name (default: %s)", defaultInterfaceName)) - cmd.Flags().StringVar(&flagLogLevel, "log-level", "", fmt.Sprintf("Log level (default: %s)", defaultLogLevel)) - cmd.Flags().StringVar(&flagHTTPAddr, "http-addr", "", "HTTP address for API server (default: disabled)") - cmd.Flags().StringVar(&flagPingInterval, "ping-interval", "", fmt.Sprintf("Ping interval (default: %s)", defaultPingInterval)) - cmd.Flags().StringVar(&flagPingTimeout, "ping-timeout", "", fmt.Sprintf("Ping timeout (default: %s)", defaultPingTimeout)) - cmd.Flags().BoolVar(&flagHolepunch, "holepunch", false, fmt.Sprintf("Enable holepunching (default: %v)", defaultHolepunch)) - cmd.Flags().StringVar(&flagTlsClientCert, "tls-client-cert", "", "TLS client certificate path") - cmd.Flags().BoolVar(&flagOverrideDNS, "override-dns", defaultOverrideDNS, fmt.Sprintf("Override system DNS for resolving internal resource alias (default: %v)", defaultOverrideDNS)) - cmd.Flags().BoolVar(&flagTunnelDNS, "tunnel-dns", defaultTunnelDNS, fmt.Sprintf("Use tunnel DNS for internal resource alias resolution (default: %v)", defaultTunnelDNS)) - cmd.Flags().StringSliceVar(&flagUpstreamDNS, "upstream-dns", nil, fmt.Sprintf("Comma separated list of DNS servers to use for external DNS resolution if overriding system DNS (default: %s)", defaultDNS)) - cmd.Flags().BoolVar(&flagAttached, "attach", false, "Run in attached mode (foreground, default is detached)") - cmd.Flags().BoolVar(&flagSilent, "silent", false, "Disable TUI and run silently (only applies to detached mode)") -} - -func init() { - addClientFlags(ClientCmd) - UpCmd.AddCommand(ClientCmd) -} - -// setupLogFile sets up file logging with rotation -func setupLogFile(logPath string) error { - logDir := filepath.Dir(logPath) - - // Rotate log file if needed - err := rotateLogFile(logDir, logPath) - if err != nil { - // Log warning but continue - log.Printf("Warning: failed to rotate log file: %v", err) - } - - // Open log file for appending - file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) - if err != nil { - return fmt.Errorf("failed to open log file: %v", err) - } - - // Set the logger output - newtLogger.GetLogger().SetOutput(file) - - // log.Printf("Logging to file: %s", logPath) - return nil -} - -// rotateLogFile handles daily log rotation -func rotateLogFile(logDir string, logFile string) error { - // Get current log file info - info, err := os.Stat(logFile) - if err != nil { - if os.IsNotExist(err) { - return nil // No current log file to rotate - } - return fmt.Errorf("failed to stat log file: %v", err) - } - - // Check if log file is from today - now := time.Now() - fileTime := info.ModTime() - - // If the log file is from today, no rotation needed - if now.Year() == fileTime.Year() && now.YearDay() == fileTime.YearDay() { - return nil - } - - // Create rotated filename with date - rotatedName := fmt.Sprintf("client-%s.log", fileTime.Format("2006-01-02")) - rotatedPath := filepath.Join(logDir, rotatedName) - - // Rename current log file to dated filename - err = os.Rename(logFile, rotatedPath) - if err != nil { - return fmt.Errorf("failed to rotate log file: %v", err) - } - - // Clean up old log files (keep last 30 days) - cleanupOldLogFiles(logDir, 30) - return nil -} - -// cleanupOldLogFiles removes log files older than specified days -func cleanupOldLogFiles(logDir string, daysToKeep int) { - cutoff := time.Now().AddDate(0, 0, -daysToKeep) - files, err := os.ReadDir(logDir) - if err != nil { - return - } - - for _, file := range files { - if !file.IsDir() && strings.HasPrefix(file.Name(), "client-") && strings.HasSuffix(file.Name(), ".log") { - filePath := filepath.Join(logDir, file.Name()) - info, err := file.Info() - if err != nil { - continue - } - if info.ModTime().Before(cutoff) { - os.Remove(filePath) - } - } - } -} diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go new file mode 100644 index 0000000..9ee83f4 --- /dev/null +++ b/cmd/up/client/client.go @@ -0,0 +1,584 @@ +package client + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/olm" + "github.com/fosrl/cli/internal/tui" + "github.com/fosrl/cli/internal/utils" + versionpkg "github.com/fosrl/cli/internal/version" + newtLogger "github.com/fosrl/newt/logger" + olmpkg "github.com/fosrl/olm/olm" + "github.com/spf13/cobra" +) + +const ( + defaultDNSServer = "8.8.8.8" + defaultEnableAPI = true + defaultSocketPath = "/var/run/olm.sock" + defaultAgent = "Pangolin CLI" +) + +type ClientUpCmdOpts struct { + ID string + Secret string + Endpoint string + OrgID string + MTU int + DNS string + InterfaceName string + LogLevel string + HTTPAddr string + PingInterval time.Duration + PingTimeout time.Duration + Holepunch bool + TlsClientCert string + Attached bool + Silent bool + OverrideDNS bool + TunnelDNS bool + UpstreamDNS []string +} + +func ClientUpCmd() *cobra.Command { + opts := ClientUpCmdOpts{} + + cmd := &cobra.Command{ + Use: "client", + Short: "Start a client connection", + Long: "Bring up a client tunneled connection", + PreRunE: func(cmd *cobra.Command, args []string) error { + // `--id` and `--secret` must be specified together + if (opts.ID == "") != (opts.Secret == "") { + return errors.New("--id and --secret must be provided together") + } + + if opts.Attached && opts.Silent { + return errors.New("--silent and --attached options conflict") + } + + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + if err := clientUpMain(cmd, &opts, args); err != nil { + os.Exit(1) + } + }, + } + + // Optional flags - if not provided, will use config or create new OLM + cmd.Flags().StringVar(&opts.ID, "id", "", "Client ID (optional, will use user info if not provided)") + cmd.Flags().StringVar(&opts.Secret, "secret", "", "Client secret (optional, will use user info if not provided)") + + // Optional flags + cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (default: selected organization if logged in)") + cmd.Flags().StringVar(&opts.Endpoint, "endpoint", "", "Client endpoint (required if not logged in)") + cmd.Flags().IntVar(&opts.MTU, "mtu", 1280, "Maximum transmission unit") + cmd.Flags().StringVar(&opts.DNS, "netstack-dns", defaultDNSServer, "DNS `server` to use for Netstack") + cmd.Flags().StringVar(&opts.InterfaceName, "interface-name", "pangolin", "Interface `name`") + cmd.Flags().StringVar(&opts.LogLevel, "log-level", "info", "Log level") + cmd.Flags().StringVar(&opts.HTTPAddr, "http-addr", "", "HTTP address for API server") + cmd.Flags().DurationVar(&opts.PingInterval, "ping-interval", 5*time.Second, "Ping `interval`") + cmd.Flags().DurationVar(&opts.PingTimeout, "ping-timeout", 5*time.Second, "Ping `timeout`") + cmd.Flags().BoolVar(&opts.Holepunch, "holepunch", true, "Enable holepunching") + cmd.Flags().StringVar(&opts.TlsClientCert, "tls-client-cert", "", "TLS client certificate `path`") + cmd.Flags().BoolVar(&opts.OverrideDNS, "override-dns", true, "Override system DNS for resolving internal resource alias") + cmd.Flags().BoolVar(&opts.TunnelDNS, "tunnel-dns", false, "Use tunnel DNS for internal resource alias resolution") + cmd.Flags().StringSliceVar(&opts.UpstreamDNS, "upstream-dns", []string{defaultDNSServer}, "List of DNS servers to use for external DNS resolution if overriding system DNS") + cmd.Flags().BoolVar(&opts.Attached, "attach", false, "Run in attached (foreground) mode, (default: detached (background) mode)") + cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Disable TUI and run silently when detached") + + return cmd +} + +func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) error { + apiClient := api.FromContext(cmd.Context()) + accountStore := config.AccountStoreFromContext(cmd.Context()) + cfg := config.ConfigFromContext(cmd.Context()) + + if runtime.GOOS == "windows" { + err := errors.New("this command is currently unsupported on Windows") + logger.Error("Error: %v", err) + return err + } + + // Check if a client is already running + olmClient := olm.NewClient("") + if olmClient.IsRunning() { + err := errors.New("a client is already running") + logger.Error("Error: %v", err) + return err + } + + // Use provided flags whenever possible. + // No user session is needed when passing these directly, + // so continue even if not logged in. + olmID := opts.ID + olmSecret := opts.Secret + + credentialsFromKeyring := olmID == "" && olmSecret == "" + + if credentialsFromKeyring { + activeAccount, err := accountStore.ActiveAccount() + if err != nil { + logger.Error("Error: %v. Run `pangolin login` to login", err) + return err + } + + // Ensure OLM credentials exist and are valid + newCredsGenerated, err := utils.EnsureOlmCredentials(apiClient, activeAccount) + if err != nil { + logger.Error("Failed to ensure OLM credentials: %v", err) + return err + } + + if newCredsGenerated { + err := accountStore.Save() + if err != nil { + logger.Error("Failed to save accounts to store: %v", err) + return err + } + } + + olmID = activeAccount.OlmCredentials.ID + olmSecret = activeAccount.OlmCredentials.Secret + } + + orgID := opts.OrgID + + // If no organization ID is specified, then use the active user's + // selected organization if possible. + if orgID == "" && credentialsFromKeyring { + activeAccount, _ := accountStore.ActiveAccount() + + if activeAccount.OrgID == "" { + err := errors.New("organization not selected") + logger.Error("Error: %v", err) + logger.Info("Run `pangolin select org` to select an organization or pass --org [id] to the command") + return err + } + + if err := utils.EnsureOrgAccess(apiClient, activeAccount); err != nil { + logger.Error("%v", err) + return err + } + + orgID = activeAccount.OrgID + } + + // Handle log file setup - if detached mode, always use log file + var logFile string + if !opts.Attached { + logFile = cfg.LogFile + } + + var endpoint string + + if opts.Endpoint == "" && credentialsFromKeyring { + activeAccount, _ := accountStore.ActiveAccount() + endpoint = activeAccount.Host + } else { + endpoint = opts.Endpoint + } + + if endpoint == "" { + err := errors.New("endpoint is required") + logger.Error("Error: %v", err) + logger.Info("Please login with a host or provide the --endpoint flag.") + return err + } + + // Handle detached mode - subprocess self without --attach flag + // Skip detached mode if already running as root (we're a subprocess spawned by sudo) + isRunningAsRoot := runtime.GOOS != "windows" && os.Geteuid() == 0 + if !opts.Attached && !isRunningAsRoot { + executable, err := os.Executable() + if err != nil { + logger.Error("Error: failed to get executable path: %v", err) + return err + } + + // Build command arguments, excluding --attach flag + cmdArgs := []string{"up", "client"} + + // Add org flag if needed (required for subprocess, which runs as + // root and won't have user's config) + if orgID != "" { + cmdArgs = append(cmdArgs, "--org", orgID) + } + + // Add all flags that were set (except --attach) + // OLM credentials are always included (from flags, config, or newly created) + cmdArgs = append(cmdArgs, "--id", olmID) + cmdArgs = append(cmdArgs, "--secret", olmSecret) + + // Always pass endpoint to subprocess (required, subprocess won't have user's config) + // Get endpoint from flag or hostname config (same logic as attached mode) + cmdArgs = append(cmdArgs, "--endpoint", endpoint) + + // Optional flags - only include if they were explicitly set + if cmd.Flags().Changed("mtu") { + cmdArgs = append(cmdArgs, "--mtu", fmt.Sprintf("%d", opts.MTU)) + } + if cmd.Flags().Changed("netstack-dns") { + cmdArgs = append(cmdArgs, "--netstack-dns", opts.DNS) + } + if cmd.Flags().Changed("interface-name") { + cmdArgs = append(cmdArgs, "--interface-name", opts.InterfaceName) + } + if cmd.Flags().Changed("log-level") { + cmdArgs = append(cmdArgs, "--log-level", opts.LogLevel) + } + if cmd.Flags().Changed("http-addr") { + cmdArgs = append(cmdArgs, "--http-addr", opts.HTTPAddr) + } + if cmd.Flags().Changed("ping-interval") { + cmdArgs = append(cmdArgs, "--ping-interval", opts.PingInterval.String()) + } + if cmd.Flags().Changed("ping-timeout") { + cmdArgs = append(cmdArgs, "--ping-timeout", opts.PingTimeout.String()) + } + if cmd.Flags().Changed("holepunch") { + if opts.Holepunch { + cmdArgs = append(cmdArgs, "--holepunch") + } else { + cmdArgs = append(cmdArgs, "--holepunch=false") + } + } + if cmd.Flags().Changed("tls-client-cert") { + cmdArgs = append(cmdArgs, "--tls-client-cert", opts.TlsClientCert) + } + if cmd.Flags().Changed("override-dns") { + if opts.OverrideDNS { + cmdArgs = append(cmdArgs, "--override-dns") + } else { + cmdArgs = append(cmdArgs, "--override-dns=false") + } + } + if cmd.Flags().Changed("tunnel-dns") { + if opts.TunnelDNS { + cmdArgs = append(cmdArgs, "--tunnel-dns") + } else { + cmdArgs = append(cmdArgs, "--tunnel-dns=false") + } + } + if cmd.Flags().Changed("upstream-dns") { + // Comma sep + cmdArgs = append(cmdArgs, "--upstream-dns", strings.Join(opts.UpstreamDNS, ",")) + } + + // Add positional args if any + cmdArgs = append(cmdArgs, extraArgs...) + + // Create command - subprocess should run with elevated permissions + var procCmd *exec.Cmd + if runtime.GOOS != "windows" { + // Use sudo with a shell wrapper to background the subprocess + // This allows sudo to exit immediately after starting the subprocess + // The subprocess needs root access for network interface creation + // Build shell command with proper quoting using printf %q + var shellArgs []string + shellArgs = append(shellArgs, executable) + shellArgs = append(shellArgs, cmdArgs...) + // Export environment variable to indicate credentials came from config + // This allows subprocess to distinguish between user-provided credentials and stored credentials + shellCmd := "" + if credentialsFromKeyring { + shellCmd = "export PANGOLIN_CREDENTIALS_FROM_KEYRING=1 && " + } + // Build command: nohup executable args >/dev/null 2>&1 & + shellCmd += "nohup" + for _, arg := range shellArgs { + shellCmd += " " + fmt.Sprintf("%q", arg) + } + shellCmd += " >/dev/null 2>&1 &" + procCmd = exec.Command("sudo", "sh", "-c", shellCmd) + // Connect stdin/stderr so sudo can prompt for password interactively + procCmd.Stdin = os.Stdin + procCmd.Stdout = nil + procCmd.Stderr = os.Stderr + } else { + err := errors.New("detached mode is not supported on Windows") + logger.Error("Error: %v", err) + return err + } + + // Start the process + if err := procCmd.Start(); err != nil { + logger.Error("Error: failed to start detached process: %v", err) + return err + } + + // Wait for sudo to complete (password prompt + subprocess start) + // The shell wrapper backgrounds the subprocess, so sudo exits immediately + if err := procCmd.Wait(); err != nil { + logger.Error("Error: failed to start subprocess: %v", err) + return err + } + + // In silent mode, skip TUI and just exit after starting the process + if opts.Silent { + return nil + } + + // Show live log preview and status + completed, err := tui.NewLogPreview(tui.LogPreviewConfig{ + LogFile: logFile, + Header: "Starting up client...", + ExitCondition: func(client *olm.Client, status *olm.StatusResponse) (bool, bool) { + // Exit when interface is registered + if status != nil && status.Registered { + return true, true + } + return false, false + }, + OnEarlyExit: func(client *olm.Client) { + // Kill the subprocess if user exits early + if client.IsRunning() { + _, _ = client.Exit() + } + }, + StatusFormatter: func(isRunning bool, status *olm.StatusResponse) string { + if !isRunning || status == nil { + return "Starting" + } else if status.Registered { + return "Registered" + } + return "Starting" + }, + }) + if err != nil { + logger.Error("Error: %v", err) + return err + } + + // Check if the process completed successfully or was killed + if !completed { + // User exited early - subprocess was killed + logger.Info("Client process killed") + } else { + // Completed successfully + logger.Success("Client interface created successfully") + } + return nil + } + + enableAPI := defaultEnableAPI + + // In detached mode, API cannot be disabled (required for status/control) + if !opts.Attached && !enableAPI { + enableAPI = true + } + + socketPath := defaultSocketPath + + upstreamDNS := make([]string, 0, len(opts.UpstreamDNS)) + for _, server := range opts.UpstreamDNS { + server = strings.TrimSpace(server) + if server == "" { + continue + } + + if !strings.Contains(server, ":") { + server = fmt.Sprintf("%s:53", server) + } + + upstreamDNS = append(upstreamDNS, server) + } + + // If no DNS servers were provided, force using + // the default server again. + if len(upstreamDNS) == 0 { + upstreamDNS = []string{fmt.Sprintf("%s:53", defaultDNSServer)} + } + + // Setup log file if specified + if logFile != "" { + if err := setupLogFile(cfg.LogFile); err != nil { + logger.Error("Error: failed to setup log file: %v", err) + return err + } + } + + // Get UserToken from config if credentials came from config + // Check environment variable to distinguish between: + // - Parent process passing id/secret from config (should fetch userToken) + // - User directly passing id/secret (should NOT fetch userToken) + var userToken string + credentialsFromKeyringEnv := os.Getenv("PANGOLIN_CREDENTIALS_FROM_KEYRING") + if credentialsFromKeyringEnv == "1" || credentialsFromKeyring { + // Credentials came from config, fetch userToken from secrets + activeAccount, err := accountStore.ActiveAccount() + if err != nil { + logger.Error("Failed to get session token: %v", err) + return err + } + + userToken = activeAccount.SessionToken + } + + // Create context for signal handling and cleanup + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer olmpkg.Close() + defer stop() + + // Create OLM GlobalConfig with hardcoded values from Swift + olmInitConfig := olmpkg.GlobalConfig{ + LogLevel: opts.LogLevel, + EnableAPI: enableAPI, + SocketPath: socketPath, + HTTPAddr: opts.HTTPAddr, + Version: versionpkg.Version, + Agent: defaultAgent, + OnTerminated: func() { + logger.Info("Client process terminated") + stop() + os.Exit(0) + }, + OnAuthError: func(statusCode int, message string) { + logger.Error("Authentication error: %d %s", statusCode, message) + stop() + os.Exit(1) + }, + OnExit: func() { + logger.Info("Client process exiting") + os.Exit(0) + }, + } + + olmConfig := olmpkg.TunnelConfig{ + Endpoint: endpoint, + ID: olmID, + Secret: olmSecret, + OrgID: orgID, + MTU: opts.MTU, + DNS: opts.DNS, + InterfaceName: opts.InterfaceName, + Holepunch: opts.Holepunch, + TlsClientCert: opts.TlsClientCert, + PingIntervalDuration: opts.PingInterval, + PingTimeoutDuration: opts.PingTimeout, + OverrideDNS: opts.OverrideDNS, + TunnelDNS: opts.TunnelDNS, + UpstreamDNS: upstreamDNS, + } + + // Add UserToken if we have it (from flag or config) + if userToken != "" { + olmConfig.UserToken = userToken + } + + // Check if running with elevated permissions (required for network interface creation) + // This check is only for attached mode; in detached mode, the subprocess runs elevated + if runtime.GOOS != "windows" { + if os.Geteuid() != 0 { + err := errors.New("elevated permissions are required for network interface creation") + logger.Error("Error: %v", err) + logger.Info("Please run with sudo or use detached mode (default) to run the subprocess elevated.") + return err + } + } + + olmpkg.Init(ctx, olmInitConfig) + if enableAPI { + _ = olmpkg.StartApi() + } + olmpkg.StartTunnel(olmConfig) + + return nil +} + +// setupLogFile sets up file logging with rotation +func setupLogFile(logPath string) error { + logDir := filepath.Dir(logPath) + + // Rotate log file if needed + err := rotateLogFile(logDir, logPath) + if err != nil { + // Log warning but continue + log.Printf("Warning: failed to rotate log file: %v", err) + } + + // Open log file for appending + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) + if err != nil { + return fmt.Errorf("failed to open log file: %v", err) + } + + // Set the logger output + newtLogger.GetLogger().SetOutput(file) + + // log.Printf("Logging to file: %s", logPath) + return nil +} + +// rotateLogFile handles daily log rotation +func rotateLogFile(logDir string, logFile string) error { + // Get current log file info + info, err := os.Stat(logFile) + if err != nil { + if os.IsNotExist(err) { + return nil // No current log file to rotate + } + return fmt.Errorf("failed to stat log file: %v", err) + } + + // Check if log file is from today + now := time.Now() + fileTime := info.ModTime() + + // If the log file is from today, no rotation needed + if now.Year() == fileTime.Year() && now.YearDay() == fileTime.YearDay() { + return nil + } + + // Create rotated filename with date + rotatedName := fmt.Sprintf("client-%s.log", fileTime.Format("2006-01-02")) + rotatedPath := filepath.Join(logDir, rotatedName) + + // Rename current log file to dated filename + err = os.Rename(logFile, rotatedPath) + if err != nil { + return fmt.Errorf("failed to rotate log file: %v", err) + } + + // Clean up old log files (keep last 30 days) + cleanupOldLogFiles(logDir, 30) + return nil +} + +// cleanupOldLogFiles removes log files older than specified days +func cleanupOldLogFiles(logDir string, daysToKeep int) { + cutoff := time.Now().AddDate(0, 0, -daysToKeep) + files, err := os.ReadDir(logDir) + if err != nil { + return + } + + for _, file := range files { + if !file.IsDir() && strings.HasPrefix(file.Name(), "client-") && strings.HasSuffix(file.Name(), ".log") { + filePath := filepath.Join(logDir, file.Name()) + info, err := file.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filePath) + } + } + } +} diff --git a/cmd/up/up.go b/cmd/up/up.go index 01b2d8e..083bbd5 100644 --- a/cmd/up/up.go +++ b/cmd/up/up.go @@ -1,38 +1,23 @@ package up import ( - "strings" - + "github.com/fosrl/cli/cmd/up/client" "github.com/spf13/cobra" - "github.com/spf13/pflag" ) -var UpCmd = &cobra.Command{ - Use: "up", - Short: "Start a client", - Long: "Bring up a client connection", - Run: func(cmd *cobra.Command, args []string) { - // _ = api.FromContext(cmd.Context()) +func UpCmd() *cobra.Command { + // If no subcommand is specified, run the `client` + // subcommand by default. + cmd := client.ClientUpCmd() - // Default to client subcommand if no subcommand is provided - // This makes "pangolin up" equivalent to "pangolin up client" - cmd.Flags().VisitAll(func(flag *pflag.Flag) { - if cmd.Flags().Changed(flag.Name) { - // Ensure stringSlice flags are passed without the bracketed representation - if flag.Value.Type() == "stringSlice" { - if vals, err := cmd.Flags().GetStringSlice(flag.Name); err == nil { - ClientCmd.Flags().Set(flag.Name, strings.Join(vals, ",")) - return - } - } - ClientCmd.Flags().Set(flag.Name, flag.Value.String()) - } - }) - ClientCmd.SetContext(cmd.Context()) - ClientCmd.Run(ClientCmd, args) - }, -} + cmd.Use = "up" + cmd.Short = "Start a connection" + cmd.Long = `Bring up a connection. + +If ran with no subcommand, 'client' is passed. +` + + cmd.AddCommand(client.ClientUpCmd()) -func init() { - addClientFlags(UpCmd) + return cmd } diff --git a/cmd/update/update.go b/cmd/update/update.go index 44e2e1b..44cfcc0 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -8,24 +8,36 @@ import ( "github.com/spf13/cobra" ) -var UpdateCmd = &cobra.Command{ - Use: "update", - Short: "Update Pangolin CLI to the latest version", - Long: "Update Pangolin CLI to the latest version by downloading and running the installation script", - Run: func(cmd *cobra.Command, args []string) { - logger.Info("Updating Pangolin CLI...") - - // Execute: curl -fsSL https://pangolin.net/get-cli.sh | bash - updateCmd := exec.Command("sh", "-c", "curl -fsSL https://static.pangolin.net/get-cli.sh | bash") - updateCmd.Stdin = os.Stdin - updateCmd.Stdout = os.Stdout - updateCmd.Stderr = os.Stderr - - if err := updateCmd.Run(); err != nil { - logger.Error("Failed to update Pangolin CLI: %v", err) - os.Exit(1) - } - - logger.Success("Pangolin CLI updated successfully!") - }, +func UpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update Pangolin CLI to the latest version", + Long: "Update Pangolin CLI to the latest version by downloading and running the installation script", + Run: func(cmd *cobra.Command, args []string) { + if err := updateMain(); err != nil { + os.Exit(1) + } + }, + } + + return cmd +} + +func updateMain() error { + logger.Info("Updating Pangolin CLI...") + + // Execute: curl -fsSL https://pangolin.net/get-cli.sh | bash + updateCmd := exec.Command("sh", "-c", "curl -fsSL https://static.pangolin.net/get-cli.sh | bash") + updateCmd.Stdin = os.Stdin + updateCmd.Stdout = os.Stdout + updateCmd.Stderr = os.Stderr + + if err := updateCmd.Run(); err != nil { + logger.Error("Failed to update Pangolin CLI: %v", err) + return err + } + + logger.Success("Pangolin CLI updated successfully!") + + return nil } diff --git a/cmd/version/version.go b/cmd/version/version.go index b12f087..2ab9a8c 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -8,27 +8,35 @@ import ( "github.com/spf13/cobra" ) -var VersionCmd = &cobra.Command{ - Use: "version", - Short: "Print the version number", - Long: "Print the version number and check for updates", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println(versionpkg.Version) +func VersionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Print the version number", + Long: "Print the version number and check for updates", + Run: func(cmd *cobra.Command, args []string) { + versionMain() + }, + } - // Check for updates - latest, err := versionpkg.CheckForUpdate() - if err != nil { - // Silently fail - don't show error to user for update check failures - return - } + return cmd +} + +func versionMain() { + fmt.Println(versionpkg.Version) + + // Check for updates + latest, err := versionpkg.CheckForUpdate() + if err != nil { + // Silently fail - don't show error to user for update check failures + return + } - if latest != nil { - logger.Warning("\nA new version is available: %s (current: %s)", latest.TagName, versionpkg.Version) - if latest.URL != "" { - logger.Info("Release: %s", latest.URL) - } - fmt.Println() - logger.Info("Run 'pangolin update' to update to the latest version") + if latest != nil { + logger.Warning("\nA new version is available: %s (current: %s)", latest.TagName, versionpkg.Version) + if latest.URL != "" { + logger.Info("Release: %s", latest.URL) } - }, + fmt.Println() + logger.Info("Run 'pangolin update' to update to the latest version") + } } diff --git a/docs/pangolin.md b/docs/pangolin.md index 63ebdfe..7607133 100644 --- a/docs/pangolin.md +++ b/docs/pangolin.md @@ -11,14 +11,14 @@ Pangolin CLI ### SEE ALSO * [pangolin auth](pangolin_auth.md) - Authentication commands -* [pangolin down](pangolin_down.md) - Stop a client +* [pangolin down](pangolin_down.md) - Stop a connection * [pangolin login](pangolin_login.md) - Login to Pangolin * [pangolin logout](pangolin_logout.md) - Logout from Pangolin * [pangolin logs](pangolin_logs.md) - View client logs -* [pangolin select](pangolin_select.md) - Select objects to work with +* [pangolin select](pangolin_select.md) - Select account information to use * [pangolin status](pangolin_status.md) - Status commands -* [pangolin up](pangolin_up.md) - Start a client +* [pangolin up](pangolin_up.md) - Start a connection * [pangolin update](pangolin_update.md) - Update Pangolin CLI to the latest version * [pangolin version](pangolin_version.md) - Print the version number -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_auth.md b/docs/pangolin_auth.md index a9e904c..26c7428 100644 --- a/docs/pangolin_auth.md +++ b/docs/pangolin_auth.md @@ -19,4 +19,4 @@ Manage authentication and sessions * [pangolin auth logout](pangolin_auth_logout.md) - Logout from Pangolin * [pangolin auth status](pangolin_auth_status.md) - Check authentication status -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_auth_login.md b/docs/pangolin_auth_login.md new file mode 100644 index 0000000..b903b33 --- /dev/null +++ b/docs/pangolin_auth_login.md @@ -0,0 +1,23 @@ +## pangolin auth login + +Login to Pangolin + +### Synopsis + +Interactive login to select your hosting option and configure access. + +``` +pangolin auth login [hostname] [flags] +``` + +### Options + +``` + -h, --help help for login +``` + +### SEE ALSO + +* [pangolin auth](pangolin_auth.md) - Authentication commands + +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_auth_logout.md b/docs/pangolin_auth_logout.md new file mode 100644 index 0000000..78e149f --- /dev/null +++ b/docs/pangolin_auth_logout.md @@ -0,0 +1,23 @@ +## pangolin auth logout + +Logout from Pangolin + +### Synopsis + +Logout and clear your session + +``` +pangolin auth logout [flags] +``` + +### Options + +``` + -h, --help help for logout +``` + +### SEE ALSO + +* [pangolin auth](pangolin_auth.md) - Authentication commands + +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_auth_status.md b/docs/pangolin_auth_status.md index 9c22139..293df0f 100644 --- a/docs/pangolin_auth_status.md +++ b/docs/pangolin_auth_status.md @@ -20,4 +20,4 @@ pangolin auth status [flags] * [pangolin auth](pangolin_auth.md) - Authentication commands -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_down.md b/docs/pangolin_down.md index 2853c73..dc4ecfb 100644 --- a/docs/pangolin_down.md +++ b/docs/pangolin_down.md @@ -1,10 +1,13 @@ ## pangolin down -Stop a client +Stop a connection ### Synopsis -Stop a client connection +Bring down a connection. + +If ran with no subcommand, 'client' is passed. + ``` pangolin down [flags] @@ -21,4 +24,4 @@ pangolin down [flags] * [pangolin](pangolin.md) - Pangolin CLI * [pangolin down client](pangolin_down_client.md) - Stop the client connection -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_down_client.md b/docs/pangolin_down_client.md index 96086d0..c23bc44 100644 --- a/docs/pangolin_down_client.md +++ b/docs/pangolin_down_client.md @@ -18,6 +18,6 @@ pangolin down client [flags] ### SEE ALSO -* [pangolin down](pangolin_down.md) - Stop a client +* [pangolin down](pangolin_down.md) - Stop a connection -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_login.md b/docs/pangolin_login.md index 0c0fa5a..220a927 100644 --- a/docs/pangolin_login.md +++ b/docs/pangolin_login.md @@ -20,4 +20,4 @@ pangolin login [hostname] [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_logout.md b/docs/pangolin_logout.md index 2515a4f..055d23e 100644 --- a/docs/pangolin_logout.md +++ b/docs/pangolin_logout.md @@ -20,4 +20,4 @@ pangolin logout [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_logs.md b/docs/pangolin_logs.md index 43f2a65..a2a508d 100644 --- a/docs/pangolin_logs.md +++ b/docs/pangolin_logs.md @@ -17,4 +17,4 @@ View and follow client logs * [pangolin](pangolin.md) - Pangolin CLI * [pangolin logs client](pangolin_logs_client.md) - View client logs -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_logs_client.md b/docs/pangolin_logs_client.md index 84053d0..1c4331c 100644 --- a/docs/pangolin_logs_client.md +++ b/docs/pangolin_logs_client.md @@ -22,4 +22,4 @@ pangolin logs client [flags] * [pangolin logs](pangolin_logs.md) - View client logs -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_select.md b/docs/pangolin_select.md index b028043..37adc49 100644 --- a/docs/pangolin_select.md +++ b/docs/pangolin_select.md @@ -1,10 +1,10 @@ ## pangolin select -Select objects to work with +Select account information to use ### Synopsis -Select objects to work with +Select account information to use ### Options @@ -18,4 +18,4 @@ Select objects to work with * [pangolin select account](pangolin_select_account.md) - Select an account * [pangolin select org](pangolin_select_org.md) - Select an organization -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_select_account.md b/docs/pangolin_select_account.md index 27e614f..874dfee 100644 --- a/docs/pangolin_select_account.md +++ b/docs/pangolin_select_account.md @@ -20,6 +20,6 @@ pangolin select account [flags] ### SEE ALSO -* [pangolin select](pangolin_select.md) - Select objects to work with +* [pangolin select](pangolin_select.md) - Select account information to use -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_select_org.md b/docs/pangolin_select_org.md index e42ebb0..778d181 100644 --- a/docs/pangolin_select_org.md +++ b/docs/pangolin_select_org.md @@ -13,12 +13,12 @@ pangolin select org [flags] ### Options ``` - -h, --help help for org - --org string Organization ID to select + -h, --help help for org + --org ID Organization ID to select ``` ### SEE ALSO -* [pangolin select](pangolin_select.md) - Select objects to work with +* [pangolin select](pangolin_select.md) - Select account information to use -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_status.md b/docs/pangolin_status.md index 4a04f7b..30f0c9f 100644 --- a/docs/pangolin_status.md +++ b/docs/pangolin_status.md @@ -6,6 +6,9 @@ Status commands View status information. +If ran with no subcommand, 'client' is passed. + + ``` pangolin status [flags] ``` @@ -22,4 +25,4 @@ pangolin status [flags] * [pangolin](pangolin.md) - Pangolin CLI * [pangolin status client](pangolin_status_client.md) - Show client status -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_status_client.md b/docs/pangolin_status_client.md index 1de6698..9a20eeb 100644 --- a/docs/pangolin_status_client.md +++ b/docs/pangolin_status_client.md @@ -21,4 +21,4 @@ pangolin status client [flags] * [pangolin status](pangolin_status.md) - Status commands -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_up.md b/docs/pangolin_up.md index 7fe9981..f9b4be9 100644 --- a/docs/pangolin_up.md +++ b/docs/pangolin_up.md @@ -1,10 +1,13 @@ ## pangolin up -Start a client +Start a connection ### Synopsis -Bring up a client connection +Bring up a connection. + +If ran with no subcommand, 'client' is passed. + ``` pangolin up [flags] @@ -13,24 +16,24 @@ pangolin up [flags] ### Options ``` - --attach Run in attached mode (foreground, default is detached) + --attach Run in attached (foreground) mode, (default: detached (background) mode) --endpoint string Client endpoint (required if not logged in) -h, --help help for up - --holepunch Enable holepunching (default: true) - --http-addr string HTTP address for API server (default: disabled) + --holepunch Enable holepunching (default true) + --http-addr string HTTP address for API server --id string Client ID (optional, will use user info if not provided) - --interface-name string Interface name (default: pangolin) - --log-level string Log level (default: info) - --mtu int MTU (default: 1280) - --netstack-dns string DNS server to use for Netstack (default: 8.8.8.8) - --org string Organization ID (optional, will use selected org if not provided) - --override-dns Override system DNS for resolving internal resource alias (default: true) (default true) - --ping-interval string Ping interval (default: 5s) - --ping-timeout string Ping timeout (default: 5s) + --interface-name name Interface name (default "pangolin") + --log-level string Log level (default "info") + --mtu int Maximum transmission unit (default 1280) + --netstack-dns server DNS server to use for Netstack (default "8.8.8.8") + --org string Organization ID (default: selected organization if logged in) + --override-dns Override system DNS for resolving internal resource alias (default true) + --ping-interval interval Ping interval (default 5s) + --ping-timeout timeout Ping timeout (default 5s) --secret string Client secret (optional, will use user info if not provided) - --silent Disable TUI and run silently (only applies to detached mode) - --tls-client-cert string TLS client certificate path - --upstream-dns strings Comma separated list of DNS servers to use for external DNS resolution if overriding system DNS (default: 8.8.8.8) + --silent Disable TUI and run silently when detached + --tls-client-cert path TLS client certificate path + --upstream-dns strings List of DNS servers to use for external DNS resolution if overriding system DNS (default [8.8.8.8]) ``` ### SEE ALSO @@ -38,4 +41,4 @@ pangolin up [flags] * [pangolin](pangolin.md) - Pangolin CLI * [pangolin up client](pangolin_up_client.md) - Start a client connection -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_up_client.md b/docs/pangolin_up_client.md index 92dbe0a..df5f98a 100644 --- a/docs/pangolin_up_client.md +++ b/docs/pangolin_up_client.md @@ -13,28 +13,28 @@ pangolin up client [flags] ### Options ``` - --attach Run in attached mode (foreground, default is detached) + --attach Run in attached (foreground) mode, (default: detached (background) mode) --endpoint string Client endpoint (required if not logged in) -h, --help help for client - --holepunch Enable holepunching (default: true) - --http-addr string HTTP address for API server (default: disabled) + --holepunch Enable holepunching (default true) + --http-addr string HTTP address for API server --id string Client ID (optional, will use user info if not provided) - --interface-name string Interface name (default: pangolin) - --log-level string Log level (default: info) - --mtu int MTU (default: 1280) - --netstack-dns string DNS server to use for Netstack (default: 8.8.8.8) - --org string Organization ID (optional, will use selected org if not provided) - --override-dns Override system DNS for resolving internal resource alias (default: true) (default true) - --ping-interval string Ping interval (default: 5s) - --ping-timeout string Ping timeout (default: 5s) + --interface-name name Interface name (default "pangolin") + --log-level string Log level (default "info") + --mtu int Maximum transmission unit (default 1280) + --netstack-dns server DNS server to use for Netstack (default "8.8.8.8") + --org string Organization ID (default: selected organization if logged in) + --override-dns Override system DNS for resolving internal resource alias (default true) + --ping-interval interval Ping interval (default 5s) + --ping-timeout timeout Ping timeout (default 5s) --secret string Client secret (optional, will use user info if not provided) - --silent Disable TUI and run silently (only applies to detached mode) - --tls-client-cert string TLS client certificate path - --upstream-dns strings Comma separated list of DNS servers to use for external DNS resolution if overriding system DNS (default: 8.8.8.8) + --silent Disable TUI and run silently when detached + --tls-client-cert path TLS client certificate path + --upstream-dns strings List of DNS servers to use for external DNS resolution if overriding system DNS (default [8.8.8.8]) ``` ### SEE ALSO -* [pangolin up](pangolin_up.md) - Start a client +* [pangolin up](pangolin_up.md) - Start a connection -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_update.md b/docs/pangolin_update.md index c9d6e3e..8b1a214 100644 --- a/docs/pangolin_update.md +++ b/docs/pangolin_update.md @@ -20,4 +20,4 @@ pangolin update [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025 diff --git a/docs/pangolin_version.md b/docs/pangolin_version.md index 68c167b..dfb20e5 100644 --- a/docs/pangolin_version.md +++ b/docs/pangolin_version.md @@ -20,4 +20,4 @@ pangolin version [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 17-Dec-2025 +###### Auto generated by spf13/cobra on 19-Dec-2025