package api import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "net/http" "os" "strings" "github.com/rcourtman/pulse-go-rewrite/internal/bootstrap" internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rs/zerolog/log" ) const ( bootstrapTokenFilename = bootstrap.TokenFilename bootstrapTokenHeader = "X-Setup-Token" ) func generateBootstrapToken() (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func loadOrCreateBootstrapToken(dataPath string) (token string, created bool, fullPath string, err error) { token, created, fullPath, _, err = bootstrap.LoadOrCreate(dataPath, generateBootstrapToken) return token, created, fullPath, err } func (r *Router) initializeBootstrapToken() { if r == nil || r.config == nil { return } // If any authentication mechanism is already configured, purge stale bootstrap tokens. // In hosted mode, auth is handled by the cloud handoff — no bootstrap needed. hasEnabledSSO := false if ssoCfg := r.ensureSSOConfig(); ssoCfg != nil { hasEnabledSSO = ssoCfg.HasEnabledProviders() } if r.config.AuthUser != "" || r.config.AuthPass != "" || r.config.HasAPITokens() || r.config.ProxyAuthSecret != "" || r.hostedMode || hasEnabledSSO { r.clearBootstrapToken() return } token, created, path, err := loadOrCreateBootstrapToken(r.config.DataPath) if err != nil { log.Error().Err(err).Msg("Failed to prepare bootstrap setup token") return } r.bootstrapTokenHash = internalauth.HashAPIToken(token) r.bootstrapTokenPath = path if created { // 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). Msg("Bootstrap setup token loaded from disk") } } func (r *Router) bootstrapTokenValid(token string) bool { if r == nil || r.bootstrapTokenHash == "" { return false } token = strings.TrimSpace(token) if token == "" { return false } return internalauth.CompareAPIToken(token, r.bootstrapTokenHash) } func (r *Router) clearBootstrapToken() { if r == nil { return } if r.bootstrapTokenPath != "" { if err := os.Remove(r.bootstrapTokenPath); err != nil && !errors.Is(err, os.ErrNotExist) { log.Warn(). Err(err). Str("token_path", r.bootstrapTokenPath). Msg("Failed to remove bootstrap setup token") } else if err == nil { log.Info(). Str("token_path", r.bootstrapTokenPath). Msg("Bootstrap setup token removed") } } r.bootstrapTokenHash = "" r.bootstrapTokenPath = "" } func (r *Router) handleValidateBootstrapToken(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if r.bootstrapTokenHash == "" { http.Error(w, "Bootstrap token unavailable. Reload the page or restart Pulse.", http.StatusConflict) return } token := strings.TrimSpace(req.Header.Get(bootstrapTokenHeader)) if token == "" { var payload struct { Token string `json:"token"` } if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { http.Error(w, "Invalid request payload", http.StatusBadRequest) return } token = strings.TrimSpace(payload.Token) } if token == "" { http.Error(w, "Bootstrap token is required", http.StatusBadRequest) return } if !r.bootstrapTokenValid(token) { log.Warn(). Str("ip", GetClientIP(req)). Msg("Rejected invalid bootstrap token validation request") http.Error(w, "Invalid bootstrap setup token", http.StatusUnauthorized) return } w.WriteHeader(http.StatusNoContent) }