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 <container> /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.
This commit is contained in:
rcourtman 2025-11-06 17:29:49 +00:00
parent 80acc5ae72
commit dd1d222ad0
4 changed files with 102 additions and 5 deletions

78
cmd/pulse/bootstrap.go Normal file
View file

@ -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("╚═══════════════════════════════════════════════════════════════════════╝")
}

View file

@ -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{

View file

@ -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).

View file

@ -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 <container> cat /data/.bootstrap_token\n"+
"Docker: docker exec <container> /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 <container> cat /data/.bootstrap_token\n"+
"Docker: docker exec <container> /app/pulse bootstrap-token\n"+
"Bare metal: cat %s\n"+
"Bare metal: pulse bootstrap-token", r.bootstrapTokenPath)
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}