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) } } }