mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:31:29 +00:00
3e3e68fd3a
- Added TestSlack_ClientHasTimeout to verify 10-second timeout - Added TestTeams_ClientHasTimeout to verify 10-second timeout - Added TestPagerDuty_ClientHasTimeout to verify 10-second timeout - Added TestOpsGenie_ClientHasTimeout to verify 10-second timeout - All notifiers already configured with 10 second timeout in New() - Tests verify timeout is set and matches expected value
241 lines
8.7 KiB
Go
241 lines
8.7 KiB
Go
package apache
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/target"
|
|
"github.com/shankar0123/certctl/internal/validation"
|
|
)
|
|
|
|
// Config represents the Apache httpd deployment target configuration.
|
|
// This configuration is used on the agent side to deploy certificates to Apache.
|
|
type Config struct {
|
|
CertPath string `json:"cert_path"` // Path where cert will be written (e.g., /etc/apache2/ssl/cert.pem)
|
|
KeyPath string `json:"key_path"` // Path where private key will be written
|
|
ChainPath string `json:"chain_path"` // Path where CA chain will be written
|
|
ReloadCommand string `json:"reload_command"` // Command to reload Apache (e.g., "apachectl graceful" or "systemctl reload apache2")
|
|
ValidateCommand string `json:"validate_command"` // Command to validate Apache config (e.g., "apachectl configtest")
|
|
}
|
|
|
|
// Connector implements the target.Connector interface for Apache httpd servers.
|
|
// This connector runs on the AGENT side and handles local certificate deployment.
|
|
type Connector struct {
|
|
config *Config
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// New creates a new Apache 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 Apache config: %w", err)
|
|
}
|
|
|
|
if cfg.CertPath == "" || cfg.ChainPath == "" {
|
|
return fmt.Errorf("Apache cert_path and chain_path are required")
|
|
}
|
|
|
|
if cfg.ReloadCommand == "" || cfg.ValidateCommand == "" {
|
|
return fmt.Errorf("Apache reload_command and validate_command are required")
|
|
}
|
|
|
|
// Validate commands to prevent injection attacks
|
|
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
|
return fmt.Errorf("invalid reload_command: %w", err)
|
|
}
|
|
if err := validation.ValidateShellCommand(cfg.ValidateCommand); err != nil {
|
|
return fmt.Errorf("invalid validate_command: %w", err)
|
|
}
|
|
|
|
c.logger.Info("validating Apache configuration",
|
|
"cert_path", cfg.CertPath,
|
|
"chain_path", cfg.ChainPath)
|
|
|
|
// Verify parent directory exists
|
|
certDir := filepath.Dir(cfg.CertPath)
|
|
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
|
return fmt.Errorf("Apache cert directory does not exist: %s", certDir)
|
|
}
|
|
|
|
// Verify validate command works
|
|
cmd := exec.CommandContext(ctx, cfg.ValidateCommand)
|
|
if err := cmd.Run(); err != nil {
|
|
c.logger.Warn("Apache config validation failed during config check",
|
|
"error", err,
|
|
"validate_command", cfg.ValidateCommand)
|
|
// Don't fail; Apache might not be installed yet
|
|
}
|
|
|
|
c.config = &cfg
|
|
c.logger.Info("Apache configuration validated")
|
|
return nil
|
|
}
|
|
|
|
// DeployCertificate writes the certificate, key, and chain to configured paths
|
|
// and reloads Apache to pick up the new certificates.
|
|
//
|
|
// Steps:
|
|
// 1. Write certificate to cert_path with mode 0644
|
|
// 2. Write private key to key_path with mode 0600 (owner-only read)
|
|
// 3. Write chain to chain_path with mode 0644
|
|
// 4. Validate Apache configuration with configtest
|
|
// 5. Execute graceful reload command
|
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
|
c.logger.Info("deploying certificate to Apache httpd",
|
|
"cert_path", c.config.CertPath,
|
|
"chain_path", c.config.ChainPath)
|
|
|
|
startTime := time.Now()
|
|
|
|
// Write certificate (0644: rw-r--r--)
|
|
if err := os.WriteFile(c.config.CertPath, []byte(request.CertPEM), 0644); err != nil {
|
|
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
|
|
c.logger.Error("certificate deployment failed", "error", err)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: c.config.CertPath,
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Write private key with secure permissions (0600: rw-------)
|
|
if c.config.KeyPath != "" && request.KeyPEM != "" {
|
|
if err := os.WriteFile(c.config.KeyPath, []byte(request.KeyPEM), 0600); err != nil {
|
|
errMsg := fmt.Sprintf("failed to write private key: %v", err)
|
|
c.logger.Error("key deployment failed", "error", err)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: c.config.KeyPath,
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
}
|
|
|
|
// Write chain (0644: rw-r--r--)
|
|
if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil {
|
|
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
|
c.logger.Error("chain deployment failed", "error", err)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: c.config.ChainPath,
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Validate Apache configuration before reload
|
|
c.logger.Debug("validating Apache configuration", "validate_command", c.config.ValidateCommand)
|
|
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
|
if output, err := validateCmd.CombinedOutput(); err != nil {
|
|
errMsg := fmt.Sprintf("Apache config validation failed: %v (output: %s)", err, string(output))
|
|
c.logger.Error("Apache validation failed", "error", err, "output", string(output))
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: c.config.CertPath,
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Graceful reload
|
|
c.logger.Debug("reloading Apache", "reload_command", c.config.ReloadCommand)
|
|
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
|
|
if output, err := reloadCmd.CombinedOutput(); err != nil {
|
|
errMsg := fmt.Sprintf("Apache reload failed: %v (output: %s)", err, string(output))
|
|
c.logger.Error("Apache reload failed", "error", err, "output", string(output))
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: c.config.CertPath,
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
deploymentDuration := time.Since(startTime)
|
|
c.logger.Info("certificate deployed to Apache successfully",
|
|
"duration", deploymentDuration.String(),
|
|
"cert_path", c.config.CertPath)
|
|
|
|
return &target.DeploymentResult{
|
|
Success: true,
|
|
TargetAddress: c.config.CertPath,
|
|
DeploymentID: fmt.Sprintf("apache-%d", time.Now().Unix()),
|
|
Message: "Certificate deployed and Apache reloaded successfully",
|
|
DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"cert_path": c.config.CertPath,
|
|
"chain_path": c.config.ChainPath,
|
|
"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 Apache deployment",
|
|
"certificate_id", request.CertificateID,
|
|
"serial", request.Serial)
|
|
|
|
startTime := time.Now()
|
|
|
|
// Validate Apache configuration
|
|
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
|
if output, err := validateCmd.CombinedOutput(); err != nil {
|
|
errMsg := fmt.Sprintf("Apache 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.CertPath,
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Verify certificate file exists and is readable
|
|
if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) {
|
|
errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath)
|
|
c.logger.Error("validation failed", "error", err)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: c.config.CertPath,
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
validationDuration := time.Since(startTime)
|
|
c.logger.Info("Apache deployment validated successfully",
|
|
"duration", validationDuration.String())
|
|
|
|
return &target.ValidationResult{
|
|
Valid: true,
|
|
Serial: request.Serial,
|
|
TargetAddress: c.config.CertPath,
|
|
Message: "Apache configuration valid and certificate accessible",
|
|
ValidatedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"validate_command": c.config.ValidateCommand,
|
|
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|