mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:31:29 +00:00
07275bf92f
Agents now report OS, architecture, IP address, hostname, and version via heartbeat using runtime.GOOS, runtime.GOARCH, and net.Dial. New migration adds columns to agents table. Heartbeat handler, service, and repository updated to accept and persist metadata. GUI shows OS/Arch in agent list and full system info in agent detail page. Apache httpd connector: separate cert/chain/key files, apachectl configtest validation, graceful reload. HAProxy connector: combined PEM file (cert+chain+key), optional config validation, reload. Both wired into agent binary's target connector switch. 14 tests for new connectors. All existing tests updated for new Heartbeat/UpdateHeartbeat signatures. Docs updated across README, architecture, concepts, and connectors guides. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
232 lines
8.3 KiB
Go
232 lines
8.3 KiB
Go
package apache
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/target"
|
|
)
|
|
|
|
// 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")
|
|
}
|
|
|
|
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, "sh", "-c", 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
|
|
}
|