From dd1d222ad0c12aeb38c61d9a770ff34432e45c03 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 6 Nov 2025 17:29:49 +0000 Subject: [PATCH] Improve bootstrap token UX for easier discovery The bootstrap token security requirement was added proactively but lacked discoverability, causing user friction during first-run setup. These improvements make the token easier to find while maintaining the security benefit. Improvements: - Display bootstrap token prominently in startup logs with ASCII box (previously: single line log message) - Add `pulse bootstrap-token` CLI command to display token on demand (Docker: docker exec /app/pulse bootstrap-token) - Improve error messages in quick-setup API to show exact commands for retrieving token when missing or invalid - Error messages now include both Docker and bare metal examples User experience improvements: - Token visible in `docker logs` output immediately - Clear instructions printed with token - Helpful error messages if token is wrong/missing - CLI helper for operators who need to retrieve token later Security unchanged: - Bootstrap token still required for first-run setup - Token still auto-deleted after successful setup - No bypass mechanism added Related to discussion about bootstrap token UX friction. --- cmd/pulse/bootstrap.go | 78 ++++++++++++++++++++++++++++++ cmd/pulse/main.go | 2 + internal/api/bootstrap_token.go | 13 +++-- internal/api/security_setup_fix.go | 14 +++++- 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 cmd/pulse/bootstrap.go diff --git a/cmd/pulse/bootstrap.go b/cmd/pulse/bootstrap.go new file mode 100644 index 0000000..4437c5b --- /dev/null +++ b/cmd/pulse/bootstrap.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var bootstrapTokenCmd = &cobra.Command{ + Use: "bootstrap-token", + Short: "Display the bootstrap setup token", + Long: `Display the bootstrap setup token required for first-time setup. + +This token is generated on first boot and must be entered in the web UI +to unlock the initial setup wizard. The token is automatically deleted +after successful setup completion.`, + Run: func(cmd *cobra.Command, args []string) { + showBootstrapToken() + }, +} + +func showBootstrapToken() { + // Determine data path (same logic as main server) + dataPath := os.Getenv("PULSE_DATA_PATH") + if dataPath == "" { + if os.Getenv("PULSE_DOCKER") == "true" { + dataPath = "/data" + } else { + dataPath = "/var/lib/pulse" + } + } + + tokenPath := filepath.Join(dataPath, ".bootstrap_token") + + // Read token file + data, err := os.ReadFile(tokenPath) + if err != nil { + if os.IsNotExist(err) { + fmt.Println("╔═══════════════════════════════════════════════════════════════════════╗") + fmt.Println("║ NO BOOTSTRAP TOKEN FOUND ║") + fmt.Println("╠═══════════════════════════════════════════════════════════════════════╣") + fmt.Println("║ Possible reasons: ║") + fmt.Println("║ • Initial setup has already been completed ║") + fmt.Println("║ • Authentication is configured (token auto-deleted) ║") + fmt.Println("║ • Server hasn't started yet (token not generated) ║") + fmt.Printf("║ • Token file not found: %-44s║\n", tokenPath) + fmt.Println("╚═══════════════════════════════════════════════════════════════════════╝") + os.Exit(1) + } + fmt.Printf("Error reading bootstrap token: %v\n", err) + os.Exit(1) + } + + token := strings.TrimSpace(string(data)) + if token == "" { + fmt.Println("Error: Bootstrap token file is empty") + os.Exit(1) + } + + // Display token prominently + fmt.Println("╔═══════════════════════════════════════════════════════════════════════╗") + fmt.Println("║ BOOTSTRAP TOKEN FOR FIRST-TIME SETUP ║") + fmt.Println("╠═══════════════════════════════════════════════════════════════════════╣") + fmt.Printf("║ Token: %-61s ║\n", token) + fmt.Printf("║ File: %-61s ║\n", tokenPath) + fmt.Println("╠═══════════════════════════════════════════════════════════════════════╣") + fmt.Println("║ Instructions: ║") + fmt.Println("║ 1. Copy the token above ║") + fmt.Println("║ 2. Open Pulse in your web browser ║") + fmt.Println("║ 3. Paste the token into the unlock screen ║") + fmt.Println("║ 4. Complete the admin account setup ║") + fmt.Println("║ ║") + fmt.Println("║ This token will be automatically deleted after successful setup. ║") + fmt.Println("╚═══════════════════════════════════════════════════════════════════════╝") +} diff --git a/cmd/pulse/main.go b/cmd/pulse/main.go index ba318b3..59aef35 100644 --- a/cmd/pulse/main.go +++ b/cmd/pulse/main.go @@ -48,6 +48,8 @@ func init() { rootCmd.AddCommand(configCmd) // Add version command rootCmd.AddCommand(versionCmd) + // Add bootstrap-token command + rootCmd.AddCommand(bootstrapTokenCmd) } var versionCmd = &cobra.Command{ diff --git a/internal/api/bootstrap_token.go b/internal/api/bootstrap_token.go index 6bfa599..28f447a 100644 --- a/internal/api/bootstrap_token.go +++ b/internal/api/bootstrap_token.go @@ -87,9 +87,16 @@ func (r *Router) initializeBootstrapToken() { r.bootstrapTokenPath = path if created { - log.Warn(). - Str("token_path", path). - Msg("Bootstrap setup token generated; retrieve it from the host to complete initial authentication") + // Display token prominently for easy discovery + log.Warn().Msg("╔═══════════════════════════════════════════════════════════════════════╗") + log.Warn().Msg("║ BOOTSTRAP TOKEN REQUIRED FOR FIRST-TIME SETUP ║") + log.Warn().Msg("╠═══════════════════════════════════════════════════════════════════════╣") + log.Warn().Msgf("║ Token: %-61s ║", token) + log.Warn().Msgf("║ File: %-61s ║", path) + log.Warn().Msg("╠═══════════════════════════════════════════════════════════════════════╣") + log.Warn().Msg("║ Copy this token and paste it into the unlock screen in your browser. ║") + log.Warn().Msg("║ This token will be automatically deleted after successful setup. ║") + log.Warn().Msg("╚═══════════════════════════════════════════════════════════════════════╝") } else { log.Info(). Str("token_path", path). diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 7e17074..5109197 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -173,7 +173,12 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { } if providedToken == "" { - http.Error(w, "Bootstrap setup token required", http.StatusUnauthorized) + errorMsg := fmt.Sprintf("Bootstrap setup token required. Retrieve it from the host:\n\n"+ + "Docker: docker exec cat /data/.bootstrap_token\n"+ + "Docker: docker exec /app/pulse bootstrap-token\n"+ + "Bare metal: cat %s\n"+ + "Bare metal: pulse bootstrap-token", r.bootstrapTokenPath) + http.Error(w, errorMsg, http.StatusUnauthorized) return } @@ -181,7 +186,12 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { log.Warn(). Str("ip", clientIP). Msg("Rejected quick setup with invalid bootstrap token") - http.Error(w, "Invalid bootstrap setup token", http.StatusUnauthorized) + errorMsg := fmt.Sprintf("Invalid bootstrap setup token. Retrieve the correct token from the host:\n\n"+ + "Docker: docker exec cat /data/.bootstrap_token\n"+ + "Docker: docker exec /app/pulse bootstrap-token\n"+ + "Bare metal: cat %s\n"+ + "Bare metal: pulse bootstrap-token", r.bootstrapTokenPath) + http.Error(w, errorMsg, http.StatusUnauthorized) return }