package api import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "net/http" "os" "strconv" "strings" "time" "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 { log.Warn(). Str("token_path", path). Msg("Bootstrap setup token created on disk; reveal it locally with `pulse bootstrap-token` or by reading the token file path") } 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 } clientIP := GetClientIP(req) if clientIP == "" { clientIP = extractRemoteIP(req.RemoteAddr) } if clientIP == "" { clientIP = req.RemoteAddr } if limiter := r.bootstrapTokenLimiter(); limiter != nil { if allowed, retryAfter := limiter.allowAt(clientIP, time.Now()); !allowed { retrySeconds := int(retryAfter.Round(time.Second) / time.Second) if retrySeconds < 1 { retrySeconds = 1 } w.Header().Set("Retry-After", strconv.Itoa(retrySeconds)) log.Warn(). Str("ip", clientIP). Int("retry_after_seconds", retrySeconds). Msg("Rejected bootstrap token validation request due to rate limit") http.Error(w, "Too many bootstrap token validation attempts", http.StatusTooManyRequests) 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", clientIP). Msg("Rejected invalid bootstrap token validation request") http.Error(w, "Invalid bootstrap setup token", http.StatusUnauthorized) return } w.WriteHeader(http.StatusNoContent) } func (r *Router) bootstrapTokenLimiter() *RateLimiter { if r == nil { return nil } if r.bootstrapTokenValidationLimiter == nil { r.bootstrapTokenValidationLimiter = NewRateLimiter(10, 5*time.Minute) } return r.bootstrapTokenValidationLimiter }