mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 23:41:48 +01:00
Introduced sync.RWMutex to protect concurrent access to configuration fields (AuthUser, AuthPass, APITokens) that are modified by the ConfigWatcher at runtime. - Added global config.Mu RWMutex in internal/config/config.go - Protected config updates in ConfigWatcher.reloadConfig() and reloadAPITokens() - Protected config reads in CheckAuth and all API token handlers - Protected Router.SetConfig() during full config reloads This prevents race conditions when .env file changes trigger config reloads while authentication handlers are reading the same fields.
708 lines
23 KiB
Go
708 lines
23 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// detectServiceName detects the actual systemd service name being used
|
|
func detectServiceName() string {
|
|
// Try common service names
|
|
services := []string{"pulse-backend", "pulse", "pulse.service", "pulse-backend.service"}
|
|
|
|
for _, service := range services {
|
|
cmd := exec.Command("systemctl", "status", service)
|
|
if err := cmd.Run(); err == nil {
|
|
// Service exists
|
|
if strings.HasSuffix(service, ".service") {
|
|
return strings.TrimSuffix(service, ".service")
|
|
}
|
|
return service
|
|
}
|
|
}
|
|
|
|
// Default to pulse-backend if no service found
|
|
return "pulse-backend"
|
|
}
|
|
|
|
// validateBcryptHash ensures the hash is complete (60 characters)
|
|
func validateBcryptHash(hash string) error {
|
|
if len(hash) != 60 {
|
|
return fmt.Errorf("invalid bcrypt hash: expected 60 characters, got %d. Hash may be truncated", len(hash))
|
|
}
|
|
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
|
|
return fmt.Errorf("invalid bcrypt hash: must start with $2a$, $2b$, or $2y$")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isRunningAsRoot checks if the process has root privileges
|
|
func isRunningAsRoot() bool {
|
|
return os.Geteuid() == 0
|
|
}
|
|
|
|
func ensureSettingsWriteScope(w http.ResponseWriter, req *http.Request) bool {
|
|
record := getAPITokenRecordFromRequest(req)
|
|
if record == nil {
|
|
return true
|
|
}
|
|
if record.HasScope(config.ScopeSettingsWrite) {
|
|
return true
|
|
}
|
|
|
|
log.Warn().
|
|
Str("token_id", record.ID).
|
|
Str("path", req.URL.Path).
|
|
Msg("API token missing settings:write scope for privileged operation")
|
|
respondMissingScope(w, config.ScopeSettingsWrite)
|
|
return false
|
|
}
|
|
|
|
// handleQuickSecuritySetupFixed is the fixed version of the Quick Security Setup
|
|
type responseCapture struct {
|
|
http.ResponseWriter
|
|
wrote bool
|
|
}
|
|
|
|
func (rc *responseCapture) WriteHeader(statusCode int) {
|
|
if !rc.wrote {
|
|
rc.wrote = true
|
|
}
|
|
rc.ResponseWriter.WriteHeader(statusCode)
|
|
}
|
|
|
|
func (rc *responseCapture) Write(b []byte) (int, error) {
|
|
rc.wrote = true
|
|
return rc.ResponseWriter.Write(b)
|
|
}
|
|
|
|
func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Apply rate limiting to prevent brute force attacks
|
|
clientIP := GetClientIP(req)
|
|
if !authLimiter.Allow(clientIP) {
|
|
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for security setup")
|
|
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var setupRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
APIToken string `json:"apiToken"`
|
|
EnableNotifications bool `json:"enableNotifications"`
|
|
DarkMode bool `json:"darkMode"`
|
|
Force bool `json:"force"`
|
|
SetupToken string `json:"setupToken"`
|
|
}
|
|
|
|
if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
authConfigured := r.config.AuthUser != "" && r.config.AuthPass != ""
|
|
setupCompleted := false
|
|
defer func() {
|
|
if setupCompleted {
|
|
r.clearBootstrapToken()
|
|
}
|
|
}()
|
|
forceRequested := setupRequest.Force
|
|
if r.config.DisableAuthEnvDetected && !authConfigured {
|
|
forceRequested = true
|
|
}
|
|
|
|
clientIP = GetClientIP(req)
|
|
recoveryToken := strings.TrimSpace(req.Header.Get("X-Recovery-Token"))
|
|
recoveryAuthorized := false
|
|
if recoveryToken != "" {
|
|
if GetRecoveryTokenStore().ValidateRecoveryTokenConstantTime(recoveryToken, clientIP) {
|
|
recoveryAuthorized = true
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("Quick security setup invoked using recovery token")
|
|
} else {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("Invalid recovery token for quick security setup")
|
|
}
|
|
}
|
|
|
|
authorized := recoveryAuthorized
|
|
|
|
// Only require authentication if credentials are already configured.
|
|
// When DISABLE_AUTH is detected but no auth exists (upgrade path from legacy),
|
|
// allow bootstrap token flow instead of demanding credentials that don't exist.
|
|
if !authorized && authConfigured {
|
|
wrapped := &responseCapture{ResponseWriter: w}
|
|
if CheckAuth(r.config, wrapped, req) {
|
|
authorized = true
|
|
} else {
|
|
if !wrapped.wrote {
|
|
http.Error(w, "Authentication required to modify existing security settings", http.StatusUnauthorized)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if !authorized && !authConfigured {
|
|
if r.bootstrapTokenHash == "" {
|
|
log.Error().Msg("Bootstrap setup token unavailable; refusing unauthenticated quick setup")
|
|
http.Error(w, "Bootstrap token unavailable; restart Pulse or inspect data directory", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
providedToken := strings.TrimSpace(req.Header.Get(bootstrapTokenHeader))
|
|
if providedToken == "" {
|
|
providedToken = strings.TrimSpace(setupRequest.SetupToken)
|
|
}
|
|
|
|
if providedToken == "" {
|
|
errorMsg := fmt.Sprintf("Bootstrap setup token required. Retrieve it from the host:\n\n"+
|
|
"Docker: docker exec <container> cat /data/.bootstrap_token\n"+
|
|
"Docker: docker exec <container> /app/pulse bootstrap-token\n"+
|
|
"Bare metal: cat %s\n"+
|
|
"Bare metal: pulse bootstrap-token", r.bootstrapTokenPath)
|
|
http.Error(w, errorMsg, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if !r.bootstrapTokenValid(providedToken) {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("Rejected quick setup with invalid bootstrap token")
|
|
errorMsg := fmt.Sprintf("Invalid bootstrap setup token. Retrieve the correct token from the host:\n\n"+
|
|
"Docker: docker exec <container> cat /data/.bootstrap_token\n"+
|
|
"Docker: docker exec <container> /app/pulse bootstrap-token\n"+
|
|
"Bare metal: cat %s\n"+
|
|
"Bare metal: pulse bootstrap-token", r.bootstrapTokenPath)
|
|
http.Error(w, errorMsg, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
authorized = true
|
|
}
|
|
|
|
if authConfigured && !authorized {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("Unauthorized quick security setup attempt rejected")
|
|
http.Error(w, "Authentication required to modify existing security settings", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if authorized && !ensureSettingsWriteScope(w, req) {
|
|
return
|
|
}
|
|
|
|
setupRequest.Force = forceRequested && authorized
|
|
|
|
if authConfigured && !setupRequest.Force {
|
|
log.Info().Msg("Security setup skipped - password auth already configured")
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"skipped": true,
|
|
"message": "Password authentication is already configured. Please remove existing security first if you want to reconfigure.",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
if setupRequest.Force {
|
|
log.Info().Msg("Quick security setup invoked with force=true - rotating credentials")
|
|
}
|
|
|
|
// Validate inputs
|
|
if setupRequest.Username == "" || setupRequest.Password == "" || setupRequest.APIToken == "" {
|
|
http.Error(w, "Username, password, and API token are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate password complexity
|
|
if err := internalauth.ValidatePasswordComplexity(setupRequest.Password); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Hash the password
|
|
hashedPassword, err := internalauth.HashPassword(setupRequest.Password)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to hash password")
|
|
http.Error(w, "Failed to process password", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Validate the bcrypt hash is complete
|
|
if err := validateBcryptHash(hashedPassword); err != nil {
|
|
log.Error().Err(err).Msg("Generated invalid bcrypt hash")
|
|
http.Error(w, fmt.Sprintf("Password hashing error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Store the raw API token for displaying to the user
|
|
rawAPIToken := setupRequest.APIToken
|
|
|
|
tokenRecord, err := config.NewAPITokenRecord(rawAPIToken, "Primary token", nil)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to construct API token record")
|
|
http.Error(w, "Failed to process API token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
primaryTokenHash := tokenRecord.Hash
|
|
|
|
if r.config.HasAPITokens() && r.config.AuthUser == "" && r.config.AuthPass == "" {
|
|
// We had API-only access before, now replacing with full security
|
|
log.Info().Msg("Replacing API-only token with new secure token")
|
|
}
|
|
|
|
// Update runtime config immediately with hashed token - no restart needed!
|
|
config.Mu.Lock()
|
|
r.config.AuthUser = setupRequest.Username
|
|
r.config.AuthPass = hashedPassword
|
|
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
|
|
r.config.SortAPITokens()
|
|
r.config.APITokenEnabled = true
|
|
config.Mu.Unlock()
|
|
|
|
if r.persistence != nil {
|
|
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to persist API tokens during security setup")
|
|
}
|
|
}
|
|
log.Info().Msg("Runtime config updated with new security settings - active immediately")
|
|
|
|
// Save system settings to system.json
|
|
systemSettings := config.DefaultSystemSettings()
|
|
systemSettings.ConnectionTimeout = 10 // Default seconds
|
|
systemSettings.AutoUpdateEnabled = false // Default disabled
|
|
if err := r.persistence.SaveSystemSettings(*systemSettings); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save system settings")
|
|
// Continue anyway - not critical for auth setup
|
|
}
|
|
|
|
// Detect environment
|
|
isSystemd := os.Getenv("INVOCATION_ID") != ""
|
|
isDocker := os.Getenv("PULSE_DOCKER") == "true"
|
|
isRoot := isRunningAsRoot()
|
|
|
|
// Detect actual service name if systemd
|
|
serviceName := ""
|
|
if isSystemd {
|
|
serviceName = detectServiceName()
|
|
log.Info().Str("service", serviceName).Msg("Detected systemd service name")
|
|
}
|
|
|
|
// Choose appropriate method based on environment
|
|
if isDocker {
|
|
// Docker: Save to /data/.env with proper quoting
|
|
envPath := filepath.Join(r.config.ConfigPath, ".env")
|
|
|
|
// CRITICAL: Use single quotes to prevent shell expansion of $ in bcrypt hash
|
|
envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
|
# Generated on %s
|
|
# IMPORTANT: Do not remove the single quotes around the password hash!
|
|
PULSE_AUTH_USER='%s'
|
|
PULSE_AUTH_PASS='%s'
|
|
API_TOKEN='%s'
|
|
API_TOKENS='%s'
|
|
PULSE_AUDIT_LOG=true
|
|
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, primaryTokenHash, primaryTokenHash)
|
|
|
|
// Ensure directory exists
|
|
if err := os.MkdirAll(r.config.ConfigPath, 0755); err != nil {
|
|
log.Error().Err(err).Str("path", r.config.ConfigPath).Msg("Failed to create config directory")
|
|
http.Error(w, "Failed to prepare configuration directory", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
|
log.Error().Err(err).Str("path", envPath).Msg("Failed to write .env file in Docker")
|
|
http.Error(w, "Failed to save security configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Info().Str("path", envPath).Msg("Docker security configuration saved")
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"method": "docker",
|
|
"deploymentType": "docker",
|
|
"requiresManualRestart": false,
|
|
"message": "Security enabled immediately! Your settings are saved and active.",
|
|
"note": "Configuration saved to /data/.env for persistence across restarts.",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
setupCompleted = true
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
} else if isSystemd && !isRoot {
|
|
// Systemd but not root (ProxmoxVE script scenario)
|
|
// Don't attempt sudo, just save config and provide instructions
|
|
|
|
envPath := filepath.Join(r.config.ConfigPath, ".env")
|
|
envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
|
# Generated on %s
|
|
PULSE_AUTH_USER='%s'
|
|
PULSE_AUTH_PASS='%s'
|
|
API_TOKEN='%s'
|
|
API_TOKENS='%s'
|
|
PULSE_AUDIT_LOG=true
|
|
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, primaryTokenHash, primaryTokenHash)
|
|
|
|
// Save to config directory (usually /etc/pulse)
|
|
if err := os.MkdirAll(r.config.ConfigPath, 0755); err != nil {
|
|
log.Error().Err(err).Str("path", r.config.ConfigPath).Msg("Failed to create config directory")
|
|
http.Error(w, "Failed to prepare configuration directory", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
|
// Try data directory as fallback
|
|
envPath = filepath.Join(r.config.DataPath, ".env")
|
|
if err := os.MkdirAll(r.config.DataPath, 0755); err != nil {
|
|
log.Error().Err(err).Str("path", r.config.DataPath).Msg("Failed to create data directory")
|
|
http.Error(w, "Failed to prepare configuration directory", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write .env file")
|
|
http.Error(w, "Failed to save security configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Create response - security is active immediately
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"method": "systemd-nonroot",
|
|
"serviceName": serviceName,
|
|
"envFile": envPath,
|
|
"deploymentType": updates.GetDeploymentType(),
|
|
"requiresManualRestart": false,
|
|
"message": "Security enabled immediately! Your settings are saved and active.",
|
|
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
setupCompleted = true
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
} else if isSystemd && isRoot {
|
|
// Systemd with root - can apply directly
|
|
|
|
// Create systemd override
|
|
overridePath := fmt.Sprintf("/etc/systemd/system/%s.service.d/override.conf", serviceName)
|
|
overrideDir := filepath.Dir(overridePath)
|
|
|
|
if err := os.MkdirAll(overrideDir, 0755); err != nil {
|
|
log.Error().Err(err).Msg("Failed to create override directory")
|
|
http.Error(w, "Failed to create systemd override directory", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
overrideContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
|
# Generated on %s
|
|
[Service]
|
|
Environment="PULSE_AUTH_USER=%s"
|
|
Environment="PULSE_AUTH_PASS=%s"
|
|
Environment="API_TOKEN=%s"
|
|
Environment="API_TOKENS=%s"
|
|
Environment="PULSE_AUDIT_LOG=true"
|
|
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, primaryTokenHash, primaryTokenHash)
|
|
|
|
if err := os.WriteFile(overridePath, []byte(overrideContent), 0644); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write systemd override")
|
|
http.Error(w, "Failed to write systemd override", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Reload systemd
|
|
if err := exec.Command("systemctl", "daemon-reload").Run(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to reload systemd daemon")
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"method": "systemd-root",
|
|
"serviceName": serviceName,
|
|
"deploymentType": updates.GetDeploymentType(),
|
|
"automatic": true,
|
|
"requiresManualRestart": false,
|
|
"message": "Security enabled immediately! Your settings are saved and active.",
|
|
"note": "Systemd override created for persistence across restarts.",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
setupCompleted = true
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
} else {
|
|
// Manual installation or development
|
|
envPath := filepath.Join(r.config.ConfigPath, ".env")
|
|
if r.config.ConfigPath == "" {
|
|
envPath = "/etc/pulse/.env"
|
|
}
|
|
|
|
envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
|
# Generated on %s
|
|
PULSE_AUTH_USER='%s'
|
|
PULSE_AUTH_PASS='%s'
|
|
API_TOKEN='%s'
|
|
API_TOKENS='%s'
|
|
PULSE_AUDIT_LOG=true
|
|
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, primaryTokenHash, primaryTokenHash)
|
|
|
|
// Try to create directory if needed
|
|
if err := os.MkdirAll(filepath.Dir(envPath), 0755); err != nil {
|
|
log.Error().Err(err).Str("path", filepath.Dir(envPath)).Msg("Failed to create env directory")
|
|
// Continue to attempt writing; error will be caught below
|
|
}
|
|
|
|
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write .env file")
|
|
// Still return success with manual instructions
|
|
}
|
|
|
|
// Get deployment type for restart instructions
|
|
deploymentType := updates.GetDeploymentType()
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"method": "manual",
|
|
"envFile": envPath,
|
|
"deploymentType": deploymentType,
|
|
"requiresManualRestart": false,
|
|
"message": "Security enabled immediately! Your settings are saved and active.",
|
|
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
setupCompleted = true
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
}
|
|
}
|
|
|
|
// HandleRegenerateAPIToken generates a new API token and updates the .env file
|
|
func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Request) {
|
|
if rq.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if !CheckAuth(r.config, w, rq) {
|
|
return
|
|
}
|
|
|
|
if !ensureSettingsWriteScope(w, rq) {
|
|
return
|
|
}
|
|
|
|
// Apply rate limiting to prevent abuse
|
|
clientIP := GetClientIP(rq)
|
|
if !authLimiter.Allow(clientIP) {
|
|
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for API token generation")
|
|
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
// Generate new token using the auth package
|
|
rawToken, err := internalauth.GenerateAPIToken()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to generate API token")
|
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
tokenRecord, err := config.NewAPITokenRecord(rawToken, "Regenerated token", nil)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to construct API token record")
|
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
config.Mu.Lock()
|
|
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
|
|
r.config.SortAPITokens()
|
|
r.config.APITokenEnabled = true
|
|
config.Mu.Unlock()
|
|
log.Info().Msg("Runtime config updated with new API token - active immediately")
|
|
|
|
if r.persistence != nil {
|
|
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to persist regenerated API token")
|
|
}
|
|
}
|
|
|
|
// Determine env file path
|
|
envPath := filepath.Join(r.config.ConfigPath, ".env")
|
|
if r.config.ConfigPath == "" {
|
|
envPath = "/etc/pulse/.env"
|
|
}
|
|
|
|
// Docker uses /data/.env
|
|
if _, err := os.Stat("/data/.env"); err == nil {
|
|
envPath = "/data/.env"
|
|
}
|
|
|
|
// Read existing .env file
|
|
content, err := os.ReadFile(envPath)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("path", envPath).Msg("Failed to read .env file")
|
|
http.Error(w, "Security configuration not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Update the API_TOKEN / API_TOKENS lines with the hashed token
|
|
lines := strings.Split(string(content), "\n")
|
|
var updatedPrimary bool
|
|
var updatedList bool
|
|
for i, line := range lines {
|
|
if strings.HasPrefix(line, "API_TOKEN=") {
|
|
lines[i] = fmt.Sprintf("API_TOKEN=%s", tokenRecord.Hash)
|
|
updatedPrimary = true
|
|
}
|
|
if strings.HasPrefix(line, "API_TOKENS=") {
|
|
lines[i] = fmt.Sprintf("API_TOKENS=%s", tokenRecord.Hash)
|
|
updatedList = true
|
|
}
|
|
}
|
|
|
|
if !updatedPrimary {
|
|
// API_TOKEN line not found, add it
|
|
lines = append(lines, fmt.Sprintf("API_TOKEN=%s", tokenRecord.Hash))
|
|
}
|
|
if !updatedList {
|
|
lines = append(lines, fmt.Sprintf("API_TOKENS=%s", tokenRecord.Hash))
|
|
}
|
|
|
|
// Write updated content back
|
|
newContent := strings.Join(lines, "\n")
|
|
if err := os.WriteFile(envPath, []byte(newContent), 0600); err != nil {
|
|
log.Error().Err(err).Msg("Failed to update .env file")
|
|
http.Error(w, "Failed to save new token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Info().Msg("API token regenerated successfully")
|
|
|
|
// Get deployment type for restart instructions
|
|
deploymentType := updates.GetDeploymentType()
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"token": rawToken, // Return the raw token to the user (only shown once!)
|
|
"deploymentType": deploymentType,
|
|
"requiresRestart": false,
|
|
"message": "New API token generated and active immediately! Save this token - it won't be shown again.",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleValidateAPIToken validates an API token without logging it
|
|
func (r *Router) HandleValidateAPIToken(w http.ResponseWriter, rq *http.Request) {
|
|
if rq.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Require authentication to prevent unauthenticated token guessing oracle
|
|
if !CheckAuth(r.config, w, rq) {
|
|
return
|
|
}
|
|
|
|
if !ensureSettingsWriteScope(w, rq) {
|
|
return
|
|
}
|
|
|
|
// Apply rate limiting to prevent brute force attacks
|
|
clientIP := GetClientIP(rq)
|
|
if !authLimiter.Allow(clientIP) {
|
|
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for API token validation")
|
|
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var validateRequest struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
if err := json.NewDecoder(rq.Body).Decode(&validateRequest); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if validateRequest.Token == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"valid": false,
|
|
"message": "Token is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check if API token auth is enabled
|
|
if !r.config.APITokenEnabled || !r.config.HasAPITokens() {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"valid": false,
|
|
"message": "API token authentication is not configured",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Validate the token (compare hash)
|
|
config.Mu.RLock()
|
|
_, isValid := r.config.ValidateAPIToken(validateRequest.Token)
|
|
config.Mu.RUnlock()
|
|
|
|
// Log validation attempt without logging the token itself
|
|
if isValid {
|
|
log.Debug().
|
|
Str("ip", clientIP).
|
|
Msg("API token validation successful")
|
|
} else {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("API token validation failed")
|
|
}
|
|
|
|
// Return validation result
|
|
response := map[string]interface{}{
|
|
"valid": isValid,
|
|
}
|
|
|
|
if isValid {
|
|
response["message"] = "Token is valid"
|
|
} else {
|
|
response["message"] = "Token is invalid"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|