mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
3cb5d5bef2
The unlock screen told Docker users to run `docker exec` from the host, which does not fit the Unraid, Portainer and TrueNAS app UIs, where the console already runs inside the container. Those `container_other` installs are the largest group stalling before first setup: in the week to 2026-09-28, 158 of 3114 persistent Docker installs never got past first start, against 94 of 5462 binary and systemd installs. The startup log also suggested revealing the token "by reading the token file path". That file holds ciphertext, as the security-privacy contract requires, so anyone following the hint pasted JSON the unlock screen rejects. The log now names the command, including the console form, and the install docs and the contract gain the same guidance. Contract-Neutral: copy-only: new unlock-screen command row and corrected startup log text; no API payload, lifecycle, storage or primitive change; owning security-privacy clause updated
180 lines
4.8 KiB
Go
180 lines
4.8 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"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)
|
|
logger := r.logEvents()
|
|
if err != nil {
|
|
logger.Error().Err(err).Msg("Failed to prepare bootstrap setup token")
|
|
return
|
|
}
|
|
|
|
r.bootstrapTokenHash = internalauth.HashAPIToken(token)
|
|
r.bootstrapTokenPath = path
|
|
|
|
if created {
|
|
logger.Warn().
|
|
Str("token_path", path).
|
|
Msg("Bootstrap setup token created; the file is encrypted, so reveal the token with `pulse bootstrap-token` (Docker: `docker exec <container> /app/pulse bootstrap-token`, or `/app/pulse bootstrap-token` from the container console)")
|
|
} else {
|
|
logger.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 != "" {
|
|
logger := r.logEvents()
|
|
if err := os.Remove(r.bootstrapTokenPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
logger.Warn().
|
|
Err(err).
|
|
Str("token_path", r.bootstrapTokenPath).
|
|
Msg("Failed to remove bootstrap setup token")
|
|
} else if err == nil {
|
|
logger.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 := decodeSecurityRequestBody(w, req, &payload); err != nil {
|
|
http.Error(w, "Invalid request payload", securityRequestErrorStatus(err))
|
|
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
|
|
}
|