package haproxy import ( "context" "encoding/json" "fmt" "log/slog" "os" "os/exec" "time" "github.com/shankar0123/certctl/internal/connector/target" "github.com/shankar0123/certctl/internal/validation" ) // Config represents the HAProxy deployment target configuration. // HAProxy expects a combined PEM file containing the certificate, chain, and private key // concatenated in a single file. type Config struct { PEMPath string `json:"pem_path"` // Path for combined PEM (cert + chain + key) ReloadCommand string `json:"reload_command"` // Command to reload HAProxy (e.g., "systemctl reload haproxy") ValidateCommand string `json:"validate_command"` // Command to validate config (e.g., "haproxy -c -f /etc/haproxy/haproxy.cfg") } // Connector implements the target.Connector interface for HAProxy servers. // This connector runs on the AGENT side and handles local certificate deployment. // HAProxy uses a combined PEM file (cert + chain + key) unlike NGINX/Apache which use // separate files. type Connector struct { config *Config logger *slog.Logger } // New creates a new HAProxy target connector with the given configuration and logger. func New(config *Config, logger *slog.Logger) *Connector { return &Connector{ config: config, logger: logger, } } // ValidateConfig checks that all required configuration paths and commands are valid. func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error { var cfg Config if err := json.Unmarshal(rawConfig, &cfg); err != nil { return fmt.Errorf("invalid HAProxy config: %w", err) } if cfg.PEMPath == "" { return fmt.Errorf("HAProxy pem_path is required") } if cfg.ReloadCommand == "" { return fmt.Errorf("HAProxy reload_command is required") } // Validate commands to prevent injection attacks if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil { return fmt.Errorf("invalid reload_command: %w", err) } if cfg.ValidateCommand != "" { if err := validation.ValidateShellCommand(cfg.ValidateCommand); err != nil { return fmt.Errorf("invalid validate_command: %w", err) } } c.logger.Info("validating HAProxy configuration", "pem_path", cfg.PEMPath) // Verify validate command works if provided if cfg.ValidateCommand != "" { cmd := exec.CommandContext(ctx, cfg.ValidateCommand) if err := cmd.Run(); err != nil { c.logger.Warn("HAProxy config validation failed during config check", "error", err, "validate_command", cfg.ValidateCommand) // Don't fail; HAProxy might not be installed yet } } c.config = &cfg c.logger.Info("HAProxy configuration validated") return nil } // DeployCertificate creates a combined PEM file (cert + chain + key) and reloads HAProxy. // // HAProxy requires all TLS material in a single file, concatenated in this order: // 1. Server certificate // 2. Intermediate/chain certificates // 3. Private key // // Steps: // 1. Build combined PEM (cert + chain + key) // 2. Write to pem_path with mode 0600 (contains private key) // 3. Optionally validate HAProxy configuration // 4. Execute reload command func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) { c.logger.Info("deploying certificate to HAProxy", "pem_path", c.config.PEMPath) startTime := time.Now() // Build combined PEM: cert + chain + key combinedPEM := request.CertPEM + "\n" if request.ChainPEM != "" { combinedPEM += request.ChainPEM + "\n" } if request.KeyPEM != "" { combinedPEM += request.KeyPEM + "\n" } // Write combined PEM with secure permissions (0600: contains private key) if err := os.WriteFile(c.config.PEMPath, []byte(combinedPEM), 0600); err != nil { errMsg := fmt.Sprintf("failed to write combined PEM: %v", err) c.logger.Error("PEM deployment failed", "error", err) return &target.DeploymentResult{ Success: false, TargetAddress: c.config.PEMPath, Message: errMsg, DeployedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } // Validate HAProxy configuration if validate command is configured if c.config.ValidateCommand != "" { c.logger.Debug("validating HAProxy configuration", "validate_command", c.config.ValidateCommand) validateCmd := exec.CommandContext(ctx, c.config.ValidateCommand) if output, err := validateCmd.CombinedOutput(); err != nil { errMsg := fmt.Sprintf("HAProxy config validation failed: %v (output: %s)", err, string(output)) c.logger.Error("HAProxy validation failed", "error", err, "output", string(output)) return &target.DeploymentResult{ Success: false, TargetAddress: c.config.PEMPath, Message: errMsg, DeployedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } } // Reload HAProxy c.logger.Debug("reloading HAProxy", "reload_command", c.config.ReloadCommand) reloadCmd := exec.CommandContext(ctx, c.config.ReloadCommand) if output, err := reloadCmd.CombinedOutput(); err != nil { errMsg := fmt.Sprintf("HAProxy reload failed: %v (output: %s)", err, string(output)) c.logger.Error("HAProxy reload failed", "error", err, "output", string(output)) return &target.DeploymentResult{ Success: false, TargetAddress: c.config.PEMPath, Message: errMsg, DeployedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } deploymentDuration := time.Since(startTime) c.logger.Info("certificate deployed to HAProxy successfully", "duration", deploymentDuration.String(), "pem_path", c.config.PEMPath) return &target.DeploymentResult{ Success: true, TargetAddress: c.config.PEMPath, DeploymentID: fmt.Sprintf("haproxy-%d", time.Now().Unix()), Message: "Combined PEM deployed and HAProxy reloaded successfully", DeployedAt: time.Now(), Metadata: map[string]string{ "pem_path": c.config.PEMPath, "duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()), }, }, nil } // ValidateDeployment verifies that the deployed certificate is valid and accessible. func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) { c.logger.Info("validating HAProxy deployment", "certificate_id", request.CertificateID, "serial", request.Serial) startTime := time.Now() // Validate HAProxy configuration if command provided if c.config.ValidateCommand != "" { validateCmd := exec.CommandContext(ctx, c.config.ValidateCommand) if output, err := validateCmd.CombinedOutput(); err != nil { errMsg := fmt.Sprintf("HAProxy config validation failed: %v (output: %s)", err, string(output)) c.logger.Error("validation failed", "error", err) return &target.ValidationResult{ Valid: false, Serial: request.Serial, TargetAddress: c.config.PEMPath, Message: errMsg, ValidatedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } } // Verify combined PEM file exists and is readable if _, err := os.Stat(c.config.PEMPath); os.IsNotExist(err) { errMsg := fmt.Sprintf("combined PEM file not found: %s", c.config.PEMPath) c.logger.Error("validation failed", "error", err) return &target.ValidationResult{ Valid: false, Serial: request.Serial, TargetAddress: c.config.PEMPath, Message: errMsg, ValidatedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } validationDuration := time.Since(startTime) c.logger.Info("HAProxy deployment validated successfully", "duration", validationDuration.String()) return &target.ValidationResult{ Valid: true, Serial: request.Serial, TargetAddress: c.config.PEMPath, Message: "HAProxy configuration valid and PEM accessible", ValidatedAt: time.Now(), Metadata: map[string]string{ "pem_path": c.config.PEMPath, "duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()), }, }, nil }