From 5fb69cb244c3961cf3aa97a767653bfcdeac17ef Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Thu, 14 Aug 2025 20:46:41 +0000 Subject: [PATCH] fix: address authentication and setup issues for v4.3.6 - Add service name detection (pulse vs pulse-backend) for ProxmoxVE compatibility - Remove sudo attempts for non-root users (addresses #6833) - Add bcrypt hash validation to ensure 60-character length - Fix Docker .env generation with proper quotes to prevent shell expansion - Skip security setup if API_TOKEN already configured - Better environment detection (Docker vs Systemd vs Manual) - Clear error messages for truncated hashes (addresses #314, #316) --- .gitignore | 4 + internal/api/router.go | 7 +- internal/api/security_setup_fix.go | 279 +++++++++++++++++++++++++++++ internal/config/config.go | 8 +- 4 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 internal/api/security_setup_fix.go diff --git a/.gitignore b/.gitignore index 73aa3b091..0ccfbc7b4 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,7 @@ package-lock.json *.test.md screenshots/ .devdata/ + +# Master plan documents (local only) +PULSE_V4_ISSUES_MASTER_PLAN.md +FIX_SUMMARY_*.md diff --git a/internal/api/router.go b/internal/api/router.go index df25e6bf2..18b74f5d6 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -241,8 +241,11 @@ func (r *Router) setupRoutes() { } }) - // Quick security setup route - r.mux.HandleFunc("/api/security/quick-setup", func(w http.ResponseWriter, req *http.Request) { + // Quick security setup route - using fixed version + r.mux.HandleFunc("/api/security/quick-setup", handleQuickSecuritySetupFixed(r)) + + // Legacy handler for backwards compatibility (will be removed) + r.mux.HandleFunc("/api/security/quick-setup-legacy", func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodPost { // Parse request body var setupRequest struct { diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go new file mode 100644 index 000000000..e8665c815 --- /dev/null +++ b/internal/api/security_setup_fix.go @@ -0,0 +1,279 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/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 +} + +// handleQuickSecuritySetupFixed is the fixed version of the Quick Security Setup +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 + } + + // Check if auth is already configured (for ProxmoxVE script compatibility) + if r.config.APIToken != "" || r.config.AuthUser != "" { + log.Info().Msg("Security setup skipped - auth already configured") + response := map[string]interface{}{ + "success": true, + "skipped": true, + "message": "Security is already configured. No changes made.", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + + // Parse request body + var setupRequest struct { + Username string `json:"username"` + Password string `json:"password"` + APIToken string `json:"apiToken"` + } + + if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate inputs + if setupRequest.Username == "" || setupRequest.Password == "" || setupRequest.APIToken == "" { + http.Error(w, "Username, password, and API token are required", http.StatusBadRequest) + return + } + + // Hash the password + hashedPassword, err := auth.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 + } + + // Hash the API token + hashedToken := auth.HashAPIToken(setupRequest.APIToken) + + // 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' +ENABLE_AUDIT_LOG=true +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedToken) + + // Ensure directory exists + os.MkdirAll(r.config.ConfigPath, 0755) + + 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", + "requiresManualRestart": true, + "message": "Security configuration saved. Restart your Docker container to apply settings.", + "note": "Your credentials have been saved to /data/.env and will persist after restart.", + } + + w.Header().Set("Content-Type", "application/json") + 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' +ENABLE_AUDIT_LOG=true +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedToken) + + // Save to config directory (usually /etc/pulse) + os.MkdirAll(r.config.ConfigPath, 0755) + + if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil { + // Try data directory as fallback + envPath = filepath.Join(r.config.DataPath, ".env") + os.MkdirAll(r.config.DataPath, 0755) + 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 manual instructions for the user + response := map[string]interface{}{ + "success": true, + "method": "systemd-nonroot", + "serviceName": serviceName, + "envFile": envPath, + "message": fmt.Sprintf("Security settings saved to %s. Restart the %s service to apply.", envPath, serviceName), + "command": fmt.Sprintf("sudo systemctl restart %s", serviceName), + "note": "You may need root privileges to restart the service.", + } + + w.Header().Set("Content-Type", "application/json") + 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="ENABLE_AUDIT_LOG=true" +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedToken) + + 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 + exec.Command("systemctl", "daemon-reload").Run() + + response := map[string]interface{}{ + "success": true, + "method": "systemd-root", + "serviceName": serviceName, + "automatic": true, + "readyToRestart": true, + "message": fmt.Sprintf("Security configured! Restart %s service to apply settings.", serviceName), + "command": fmt.Sprintf("systemctl restart %s", serviceName), + } + + w.Header().Set("Content-Type", "application/json") + 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' +ENABLE_AUDIT_LOG=true +`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedToken) + + // Try to create directory if needed + os.MkdirAll(filepath.Dir(envPath), 0755) + + 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 + } + + response := map[string]interface{}{ + "success": true, + "method": "manual", + "envFile": envPath, + "message": "Security configuration saved. Restart Pulse to apply settings.", + "note": fmt.Sprintf("Configuration saved to %s", envPath), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + } + } +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 12a8153ed..759c05f0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -270,8 +270,14 @@ func Load() (*Config, error) { // because we can't modify environment variables if !IsPasswordHashed(authPass) { log.Warn().Msg("Password is stored in plain text - run 'pulse hash-password' to generate a secure hash") + } else { + // Validate bcrypt hash is complete (60 characters) + if len(authPass) != 60 { + log.Error().Int("length", len(authPass)).Msg("Bcrypt hash appears truncated! Expected 60 characters. Authentication may fail.") + log.Error().Msg("Ensure the full hash is enclosed in single quotes in your .env file or Docker environment") + } } - log.Info().Bool("is_hashed", IsPasswordHashed(authPass)).Msg("Loaded auth password from env var") + log.Info().Bool("is_hashed", IsPasswordHashed(authPass)).Int("length", len(authPass)).Msg("Loaded auth password from env var") } if updateChannel := os.Getenv("UPDATE_CHANNEL"); updateChannel != "" { cfg.UpdateChannel = updateChannel