Files
pulse/internal/api/bootstrap_token.go
T
Richard Courtman 3cb5d5bef2 Show container-console route for the setup token
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
2026-09-28 15:29:15 +01:00

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
}