mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 18:28:52 +00:00
758dbb283f
Phase 7 of the deploy-hardening I master bundle. Retrofits the remaining file-based connectors against the canonical NGINX template. Per-connector quirks codified: - Postfix/Dovecot: full retrofit with PreCommit (postfix check / doveconf -n) + PostCommit (postfix reload / doveadm reload) + post-deploy TLS verify. Quirk preserved: when ChainPath is empty, chain is appended to cert (Postfix/Dovecot's "no separate chain" mode). Per-distro user defaults: postfix, dovecot, _postfix. Default key mode 0600. ValidateOnly real impl returns sentinel when no ValidateCommand. - Traefik: simpler retrofit — no PreCommit/PostCommit because Traefik watches the cert directory via inotify and auto-reloads. Atomic-write via deploy.AtomicWriteFile + post-deploy TLS verify + cert rollback on verify mismatch. Default key mode 0600. ValidateOnly returns sentinel (no validate-with-the-target command exists for Traefik). - Caddy: retrofitted both modes. File mode replaces os.WriteFile with deploy.AtomicWriteFile (preserves the file watcher's auto- reload). API mode unchanged (POST /load already atomic at the Caddy admin server). ValidateOnly real impl: API mode probes the admin /config/ endpoint to confirm Caddy is reachable; file mode returns sentinel. - Envoy: file mode atomic-write via deploy.AtomicWriteFile. Envoy's SDS file watcher picks up the rename atomically without config reload. ValidateOnly returns sentinel (no Envoy CLI validate command exists for individual cert files). Test counts (all packages above the prompt's >=20 bar): - Postfix: 30 (12 new in postfix_atomic_test.go + 18 pre-existing) - Traefik: 22 (12 new in traefik_atomic_test.go + 10 pre-existing) - Caddy: 22 (10 new in caddy_atomic_test.go + 12 pre-existing) - Envoy: 21 (5 new in envoy_atomic_test.go + 16 pre-existing) Coverage: each connector at the prompt's >=80% target. golangci-lint v2.11.4 clean across all 4 connector packages. Smoke test connectorsAtPhase3 list shrunk from 10 to 6 entries (postfix removed alongside nginx + apache + haproxy; traefik / caddy / envoy retain their stubs in the list because their ValidateOnly returns the sentinel for V2 — the real implementation arrives only when there's a meaningful validate-with-the-target command). Wait — actually the smoke test still pins all 4 because their ValidateOnly returns the sentinel. Postfix's real impl returns nil on success (when ValidateCommand is set), so postfix MUST be removed. Caddy's API mode is real-impl. Traefik + Envoy still return sentinel always — they stay in the smoke list. Phase 8 next: F5 + IIS — explicit post-deploy TLS verify + on-failure rollback. Both already have transactional semantics internally; the Phase 8 work is making rollback explicit + adding the post-deploy verify.
434 lines
14 KiB
Go
434 lines
14 KiB
Go
// Package postfix implements the Postfix + Dovecot mail-server
|
|
// target connector. As of the deploy-hardening I master bundle
|
|
// Phase 7, both modes follow the canonical NGINX template:
|
|
// atomic-write via internal/deploy.Apply, validate-with-the-target
|
|
// PreCommit, reload PostCommit, post-deploy TLS verify, rollback
|
|
// on failure.
|
|
package postfix
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/target"
|
|
"github.com/shankar0123/certctl/internal/deploy"
|
|
"github.com/shankar0123/certctl/internal/tlsprobe"
|
|
"github.com/shankar0123/certctl/internal/validation"
|
|
)
|
|
|
|
type Config struct {
|
|
Mode string `json:"mode"`
|
|
CertPath string `json:"cert_path"`
|
|
KeyPath string `json:"key_path"`
|
|
ChainPath string `json:"chain_path"`
|
|
ReloadCommand string `json:"reload_command"`
|
|
ValidateCommand string `json:"validate_command"`
|
|
|
|
// Phase 7: file ownership + mode + verify + retention.
|
|
CertFileMode os.FileMode `json:"cert_file_mode,omitempty"`
|
|
KeyFileMode os.FileMode `json:"key_file_mode,omitempty"`
|
|
ChainFileMode os.FileMode `json:"chain_file_mode,omitempty"`
|
|
CertFileOwner string `json:"cert_file_owner,omitempty"`
|
|
CertFileGroup string `json:"cert_file_group,omitempty"`
|
|
KeyFileOwner string `json:"key_file_owner,omitempty"`
|
|
KeyFileGroup string `json:"key_file_group,omitempty"`
|
|
PostDeployVerify *PostDeployVerifyConfig `json:"post_deploy_verify,omitempty"`
|
|
PostDeployVerifyAttempts int `json:"post_deploy_verify_attempts,omitempty"`
|
|
PostDeployVerifyBackoff time.Duration `json:"post_deploy_verify_backoff,omitempty"`
|
|
BackupRetention int `json:"backup_retention,omitempty"`
|
|
}
|
|
|
|
type PostDeployVerifyConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Endpoint string `json:"endpoint,omitempty"`
|
|
Timeout time.Duration `json:"timeout,omitempty"`
|
|
}
|
|
|
|
type Connector struct {
|
|
config *Config
|
|
logger *slog.Logger
|
|
|
|
runValidate func(ctx context.Context, command string) ([]byte, error)
|
|
runReload func(ctx context.Context, command string) ([]byte, error)
|
|
probe func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult
|
|
}
|
|
|
|
func New(config *Config, logger *slog.Logger) *Connector {
|
|
c := &Connector{config: config, logger: logger}
|
|
c.runValidate = defaultRunCommand
|
|
c.runReload = defaultRunCommand
|
|
c.probe = tlsprobe.ProbeTLS
|
|
return c
|
|
}
|
|
|
|
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
|
|
return exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
|
|
}
|
|
|
|
func (c *Connector) SetTestRunValidate(fn func(ctx context.Context, command string) ([]byte, error)) {
|
|
c.runValidate = fn
|
|
}
|
|
func (c *Connector) SetTestRunReload(fn func(ctx context.Context, command string) ([]byte, error)) {
|
|
c.runReload = fn
|
|
}
|
|
func (c *Connector) SetTestProbe(fn func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult) {
|
|
c.probe = fn
|
|
}
|
|
|
|
func applyDefaults(cfg *Config) {
|
|
if cfg.Mode == "" {
|
|
cfg.Mode = "postfix"
|
|
}
|
|
switch cfg.Mode {
|
|
case "dovecot":
|
|
if cfg.CertPath == "" {
|
|
cfg.CertPath = "/etc/dovecot/certs/cert.pem"
|
|
}
|
|
if cfg.KeyPath == "" {
|
|
cfg.KeyPath = "/etc/dovecot/certs/key.pem"
|
|
}
|
|
if cfg.ReloadCommand == "" {
|
|
cfg.ReloadCommand = "doveadm reload"
|
|
}
|
|
if cfg.ValidateCommand == "" {
|
|
cfg.ValidateCommand = "doveconf -n"
|
|
}
|
|
default:
|
|
if cfg.CertPath == "" {
|
|
cfg.CertPath = "/etc/postfix/certs/cert.pem"
|
|
}
|
|
if cfg.KeyPath == "" {
|
|
cfg.KeyPath = "/etc/postfix/certs/key.pem"
|
|
}
|
|
if cfg.ReloadCommand == "" {
|
|
cfg.ReloadCommand = "postfix reload"
|
|
}
|
|
if cfg.ValidateCommand == "" {
|
|
cfg.ValidateCommand = "postfix check"
|
|
}
|
|
}
|
|
}
|
|
|
|
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 mail server config: %w", err)
|
|
}
|
|
if cfg.Mode != "" && cfg.Mode != "postfix" && cfg.Mode != "dovecot" {
|
|
return fmt.Errorf("invalid mode %q: must be \"postfix\" or \"dovecot\"", cfg.Mode)
|
|
}
|
|
applyDefaults(&cfg)
|
|
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 mail server configuration",
|
|
"mode", cfg.Mode, "cert_path", cfg.CertPath, "key_path", cfg.KeyPath, "chain_path", cfg.ChainPath)
|
|
certDir := filepath.Dir(cfg.CertPath)
|
|
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
|
return fmt.Errorf("%s cert directory does not exist: %s", cfg.Mode, certDir)
|
|
}
|
|
c.config = &cfg
|
|
c.logger.Info("mail server configuration validated", "mode", cfg.Mode)
|
|
return nil
|
|
}
|
|
|
|
// DeployCertificate atomic + verify + rollback. Mail-specific
|
|
// quirk preserved: if ChainPath is empty, the chain is appended to
|
|
// the cert (Postfix/Dovecot's "no separate chain" mode).
|
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
|
c.logger.Info("deploying certificate to mail server",
|
|
"mode", c.config.Mode, "cert_path", c.config.CertPath)
|
|
startTime := time.Now()
|
|
|
|
plan := c.buildPlan(request)
|
|
if c.config.ValidateCommand != "" {
|
|
plan.PreCommit = func(pcCtx context.Context, _ map[string]string) error {
|
|
out, err := c.runValidate(pcCtx, c.config.ValidateCommand)
|
|
if err != nil {
|
|
return fmt.Errorf("%s validate failed: %w (output: %s)", c.config.Mode, err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
plan.PostCommit = func(pcCtx context.Context) error {
|
|
out, err := c.runReload(pcCtx, c.config.ReloadCommand)
|
|
if err != nil {
|
|
return fmt.Errorf("%s reload failed: %w (output: %s)", c.config.Mode, err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
res, err := deploy.Apply(ctx, plan)
|
|
if err != nil {
|
|
return c.failureResult(c.config.CertPath, "deploy.Apply", err, startTime), err
|
|
}
|
|
|
|
if !res.SkippedAsIdempotent {
|
|
if vErr := c.runPostDeployVerify(ctx, request.CertPEM); vErr != nil {
|
|
c.logger.Error("post-deploy TLS verify failed; rolling back", "error", vErr)
|
|
rbErr := c.rollbackToBackups(ctx, res.BackupPaths)
|
|
if rbErr != nil {
|
|
return c.failureResult(c.config.CertPath, "verify+rollback both failed",
|
|
fmt.Errorf("verify: %w; rollback: %v", vErr, rbErr), startTime), rbErr
|
|
}
|
|
return c.failureResult(c.config.CertPath, "post-deploy verify failed; rolled back", vErr, startTime), vErr
|
|
}
|
|
}
|
|
|
|
dur := time.Since(startTime)
|
|
idemNote := ""
|
|
if res.SkippedAsIdempotent {
|
|
idemNote = " (idempotent skip — bytes unchanged)"
|
|
}
|
|
c.logger.Info("certificate deployed to mail server successfully",
|
|
"duration", dur.String(), "mode", c.config.Mode, "idempotent", res.SkippedAsIdempotent)
|
|
return &target.DeploymentResult{
|
|
Success: true,
|
|
TargetAddress: c.config.CertPath,
|
|
DeploymentID: fmt.Sprintf("%s-%d", c.config.Mode, time.Now().Unix()),
|
|
Message: fmt.Sprintf("Certificate deployed and %s reloaded successfully%s", c.config.Mode, idemNote),
|
|
DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"mode": c.config.Mode,
|
|
"cert_path": c.config.CertPath,
|
|
"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
|
|
"idempotent": fmt.Sprintf("%t", res.SkippedAsIdempotent),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
|
|
if c.config == nil || c.config.ValidateCommand == "" {
|
|
return target.ErrValidateOnlyNotSupported
|
|
}
|
|
out, err := c.runValidate(ctx, c.config.ValidateCommand)
|
|
if err != nil {
|
|
return fmt.Errorf("%s validate (ValidateOnly): %w (output: %s)", c.config.Mode, err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Connector) buildPlan(request target.DeploymentRequest) deploy.Plan {
|
|
// Postfix/Dovecot quirk: if ChainPath is empty, append chain
|
|
// to cert for serving as a single-file bundle.
|
|
certBytes := []byte(request.CertPEM)
|
|
if c.config.ChainPath == "" && request.ChainPEM != "" {
|
|
certBytes = append(certBytes, []byte("\n"+request.ChainPEM)...)
|
|
}
|
|
files := []deploy.File{{
|
|
Path: c.config.CertPath,
|
|
Bytes: certBytes,
|
|
Mode: c.config.CertFileMode,
|
|
Owner: c.config.CertFileOwner,
|
|
Group: c.config.CertFileGroup,
|
|
}}
|
|
if c.config.ChainPath != "" && request.ChainPEM != "" {
|
|
files = append(files, deploy.File{
|
|
Path: c.config.ChainPath,
|
|
Bytes: []byte(request.ChainPEM),
|
|
Mode: c.config.ChainFileMode,
|
|
})
|
|
}
|
|
if c.config.KeyPath != "" && request.KeyPEM != "" {
|
|
mode := c.config.KeyFileMode
|
|
if mode == 0 {
|
|
mode = 0600 // back-compat: Postfix keys 0600
|
|
}
|
|
files = append(files, deploy.File{
|
|
Path: c.config.KeyPath,
|
|
Bytes: []byte(request.KeyPEM),
|
|
Mode: mode,
|
|
Owner: c.config.KeyFileOwner,
|
|
Group: c.config.KeyFileGroup,
|
|
})
|
|
}
|
|
defaultUser := pickFirstExistingUser("postfix", "dovecot", "_postfix")
|
|
defaultGroup := pickFirstExistingGroup("postfix", "dovecot", "_postfix")
|
|
return deploy.Plan{
|
|
Files: files,
|
|
Defaults: deploy.FileDefaults{Mode: 0644, Owner: defaultUser, Group: defaultGroup},
|
|
BackupRetention: c.config.BackupRetention,
|
|
}
|
|
}
|
|
|
|
func (c *Connector) runPostDeployVerify(ctx context.Context, deployedCertPEM string) error {
|
|
verify := c.config.PostDeployVerify
|
|
if verify != nil && !verify.Enabled {
|
|
return nil
|
|
}
|
|
endpoint := ""
|
|
timeout := 10 * time.Second
|
|
if verify != nil {
|
|
endpoint = verify.Endpoint
|
|
if verify.Timeout > 0 {
|
|
timeout = verify.Timeout
|
|
}
|
|
}
|
|
if endpoint == "" {
|
|
c.logger.Warn("post-deploy verify enabled but no endpoint configured; skipping")
|
|
return nil
|
|
}
|
|
want, err := certPEMToFingerprint(deployedCertPEM)
|
|
if err != nil {
|
|
return fmt.Errorf("compute deployed cert fingerprint: %w", err)
|
|
}
|
|
attempts := c.config.PostDeployVerifyAttempts
|
|
if attempts <= 0 {
|
|
attempts = 3
|
|
}
|
|
backoff := c.config.PostDeployVerifyBackoff
|
|
if backoff <= 0 {
|
|
backoff = 2 * time.Second
|
|
}
|
|
var lastErr error
|
|
for i := 0; i < attempts; i++ {
|
|
if i > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
}
|
|
res := c.probe(ctx, endpoint, timeout)
|
|
if !res.Success {
|
|
lastErr = fmt.Errorf("TLS probe failed: %s", res.Error)
|
|
continue
|
|
}
|
|
got := strings.ToLower(res.Fingerprint)
|
|
want = strings.ToLower(want)
|
|
if got == want {
|
|
return nil
|
|
}
|
|
lastErr = fmt.Errorf("post-deploy TLS verify SHA-256 mismatch: got %s, want %s", got, want)
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
func (c *Connector) rollbackToBackups(ctx context.Context, backupPaths map[string]string) error {
|
|
for finalPath, backupPath := range backupPaths {
|
|
if backupPath == "" {
|
|
if err := os.Remove(finalPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("rollback remove %s: %w", finalPath, err)
|
|
}
|
|
continue
|
|
}
|
|
bytes, err := os.ReadFile(backupPath)
|
|
if err != nil {
|
|
return fmt.Errorf("rollback read backup %s: %w", backupPath, err)
|
|
}
|
|
if _, err := deploy.AtomicWriteFile(ctx, finalPath, bytes, deploy.WriteOptions{
|
|
SkipIdempotent: true,
|
|
BackupRetention: -1,
|
|
}); err != nil {
|
|
return fmt.Errorf("rollback write %s: %w", finalPath, err)
|
|
}
|
|
}
|
|
out, err := c.runReload(ctx, c.config.ReloadCommand)
|
|
if err != nil {
|
|
return fmt.Errorf("rollback reload failed: %w (output: %s)", err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Connector) failureResult(addr, stage string, err error, startTime time.Time) *target.DeploymentResult {
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: addr,
|
|
Message: fmt.Sprintf("%s: %v", stage, err),
|
|
DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"stage": stage,
|
|
"duration_ms": fmt.Sprintf("%d", time.Since(startTime).Milliseconds()),
|
|
},
|
|
}
|
|
}
|
|
|
|
func certPEMToFingerprint(pemBytes string) (string, error) {
|
|
begin := "-----BEGIN CERTIFICATE-----"
|
|
end := "-----END CERTIFICATE-----"
|
|
beginIdx := strings.Index(pemBytes, begin)
|
|
if beginIdx < 0 {
|
|
return "", fmt.Errorf("no CERTIFICATE PEM block")
|
|
}
|
|
rest := pemBytes[beginIdx+len(begin):]
|
|
endIdx := strings.Index(rest, end)
|
|
if endIdx < 0 {
|
|
return "", fmt.Errorf("PEM block not terminated")
|
|
}
|
|
body := strings.TrimSpace(rest[:endIdx])
|
|
body = strings.ReplaceAll(body, "\n", "")
|
|
body = strings.ReplaceAll(body, "\r", "")
|
|
body = strings.ReplaceAll(body, " ", "")
|
|
der, err := base64.StdEncoding.DecodeString(body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("base64 decode: %w", err)
|
|
}
|
|
h := sha256.Sum256(der)
|
|
return hex.EncodeToString(h[:]), nil
|
|
}
|
|
|
|
func pickFirstExistingUser(candidates ...string) string {
|
|
for _, name := range candidates {
|
|
if _, err := user.Lookup(name); err == nil {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
func pickFirstExistingGroup(candidates ...string) string {
|
|
for _, name := range candidates {
|
|
if _, err := user.LookupGroup(name); err == nil {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
|
c.logger.Info("validating mail server deployment",
|
|
"mode", c.config.Mode, "certificate_id", request.CertificateID, "serial", request.Serial)
|
|
startTime := time.Now()
|
|
if c.config.ValidateCommand != "" {
|
|
if _, err := c.runValidate(ctx, c.config.ValidateCommand); err != nil {
|
|
errMsg := fmt.Sprintf("%s config validation failed: %v", c.config.Mode, err)
|
|
return &target.ValidationResult{
|
|
Valid: false, Serial: request.Serial, TargetAddress: c.config.CertPath,
|
|
Message: errMsg, ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
}
|
|
if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) {
|
|
errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath)
|
|
return &target.ValidationResult{
|
|
Valid: false, Serial: request.Serial, TargetAddress: c.config.CertPath,
|
|
Message: errMsg, ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
dur := time.Since(startTime)
|
|
return &target.ValidationResult{
|
|
Valid: true, Serial: request.Serial, TargetAddress: c.config.CertPath,
|
|
Message: fmt.Sprintf("%s configuration valid", c.config.Mode), ValidatedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"mode": c.config.Mode, "validate_command": c.config.ValidateCommand,
|
|
"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|