mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-10 20:25:41 +00:00
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)
This commit is contained in:
parent
95aa342e7a
commit
5fb69cb244
4 changed files with 295 additions and 3 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
279
internal/api/security_setup_fix.go
Normal file
279
internal/api/security_setup_fix.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue