Files
Pulse/internal/api/security_setup_fix.go
rcourtman 3e2824a7ff feat: remove Enterprise badges, simplify Pro upgrade prompts
- Replace barrel import in AuditLogPanel.tsx to fix ad-blocker crash
- Remove all Enterprise/Pro badges from nav and feature headers
- Simplify upgrade CTAs to clean 'Upgrade to Pro' links
- Update docs: PULSE_PRO.md, API.md, README.md, SECURITY.md
- Align terminology: single Pro tier, no separate Enterprise tier

Also includes prior refactoring:
- Move auth package to pkg/auth for enterprise reuse
- Export server functions for testability
- Stabilize CLI tests
2026-01-09 16:51:08 +00:00

720 lines
24 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"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")
// Clear any agents that connected during the brief unauthenticated setup window.
// This prevents stale/unauthorized agent data from appearing in the wizard.
if r.monitor != nil {
hostCleared, dockerCleared := r.monitor.ClearUnauthenticatedAgents()
if hostCleared > 0 || dockerCleared > 0 {
log.Info().
Int("hosts", hostCleared).
Int("dockerHosts", dockerCleared).
Msg("Cleared agents that connected before security was configured")
}
}
// 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)
}