mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-16 11:19:11 +00:00
178 lines
4.6 KiB
Go
178 lines
4.6 KiB
Go
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
|
|
}
|