Pulse/internal/api/bootstrap_token.go
2026-03-18 16:06:30 +00:00

149 lines
4.9 KiB
Go

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)
}