// Copyright 2026 certctl LLC. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 // Package apache implements the Apache httpd target connector. // As of the deploy-hardening I master bundle Phase 5, Apache // follows the canonical pattern established by NGINX (Phase 4): // atomic-write all files via internal/deploy.Apply, run // `apachectl configtest` as PreCommit, run `apachectl graceful` as // PostCommit, post-deploy TLS handshake to verify the new cert is // being served, rollback on any failure. // // Apache-specific quirks codified here: // // - Validate command is `apachectl configtest` (NOT `apachectl -t` // — that flag exists but the operator-facing convention is // configtest). // - Reload command is `apachectl graceful` for zero-downtime // reload (NOT `apachectl restart` which drops in-flight TLS // sessions). // - Separate cert / chain / key files (vs HAProxy's combined // PEM blob). package apache 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/certctl-io/certctl/internal/connector/target" "github.com/certctl-io/certctl/internal/deploy" "github.com/certctl-io/certctl/internal/tlsprobe" "github.com/certctl-io/certctl/internal/validation" ) // Config represents the Apache httpd deployment target // configuration. Phase 5 (deploy-hardening I) added the // CertFileMode/KeyFileMode/...-Owner/-Group overrides + the // PostDeployVerify config + BackupRetention. Pre-existing fields // (CertPath/KeyPath/ChainPath/ReloadCommand/ValidateCommand) // preserved for back-compat. type Config struct { CertPath string `json:"cert_path"` KeyPath string `json:"key_path,omitempty"` ChainPath string `json:"chain_path,omitempty"` ReloadCommand string `json:"reload_command"` ValidateCommand string `json:"validate_command"` // Phase 5: file ownership + mode overrides. CertFileMode os.FileMode `json:"cert_file_mode,omitempty"` ChainFileMode os.FileMode `json:"chain_file_mode,omitempty"` KeyFileMode os.FileMode `json:"key_file_mode,omitempty"` CertFileOwner string `json:"cert_file_owner,omitempty"` CertFileGroup string `json:"cert_file_group,omitempty"` ChainFileOwner string `json:"chain_file_owner,omitempty"` ChainFileGroup string `json:"chain_file_group,omitempty"` KeyFileOwner string `json:"key_file_owner,omitempty"` KeyFileGroup string `json:"key_file_group,omitempty"` // Phase 5: post-deploy TLS verification (frozen-decision-0.3 // default ON). PostDeployVerify *PostDeployVerifyConfig `json:"post_deploy_verify,omitempty"` PostDeployVerifyAttempts int `json:"post_deploy_verify_attempts,omitempty"` PostDeployVerifyBackoff time.Duration `json:"post_deploy_verify_backoff,omitempty"` PostDeployVerifyMaxBackoff time.Duration `json:"post_deploy_verify_max_backoff,omitempty"` // Phase 5: backup retention (default 3, -1 to disable). BackupRetention int `json:"backup_retention,omitempty"` } // PostDeployVerifyConfig matches the NGINX shape for cross- // connector consistency. type PostDeployVerifyConfig struct { Enabled bool `json:"enabled"` Endpoint string `json:"endpoint,omitempty"` Timeout time.Duration `json:"timeout,omitempty"` } // Connector implements the target.Connector interface for Apache // httpd. Test seams (runValidate / runReload / probe) mirror NGINX. 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 } // New constructs an Apache connector with default test seams // pointing to the production exec / tlsprobe paths. 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 } // Phase 7 SEC-H2 closure (2026-05-14): argv-form exec instead of // `sh -c`. See nginx connector's defaultRunCommand for the // rationale + threat model. ValidateShellCommand at config-time + // SplitShellCommand at exec-time provide defense in depth; the argv // exec is what actually eliminates the injection vector. func defaultRunCommand(ctx context.Context, command string) ([]byte, error) { argv, err := validation.SplitShellCommand(command) if err != nil { return nil, fmt.Errorf("invalid reload/validate command: %w", err) } cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) return cmd.CombinedOutput() } // SetTestRunValidate / SetTestRunReload / SetTestProbe — test-only // hooks mirroring nginx package; allow tests to skip exec without // `apachectl` on PATH. 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 } // ValidateConfig — preserved verbatim from pre-Phase-5 implementation. 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") } 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) certDir := filepath.Dir(cfg.CertPath) if _, err := os.Stat(certDir); os.IsNotExist(err) { return fmt.Errorf("Apache cert directory does not exist: %s", certDir) } c.config = &cfg c.logger.Info("Apache configuration validated") return nil } // DeployCertificate — Phase 5 atomic + verify + rollback. Mirrors // the NGINX template; differences are operator-facing command // names (`apachectl configtest`, `apachectl graceful`). 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() plan := c.buildPlan(request) plan.PreCommit = func(pcCtx context.Context, _ map[string]string) error { out, err := c.runValidate(pcCtx, c.config.ValidateCommand) if err != nil { return fmt.Errorf("apachectl configtest failed: %w (output: %s)", 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("apachectl graceful failed: %w (output: %s)", 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, "cert_path", c.config.CertPath) 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 Apache successfully", "duration", dur.String(), "cert_path", c.config.CertPath, "idempotent", res.SkippedAsIdempotent) return &target.DeploymentResult{ Success: true, TargetAddress: c.config.CertPath, DeploymentID: fmt.Sprintf("apache-%d", time.Now().Unix()), Message: "Certificate deployed and Apache reloaded successfully" + idemNote, DeployedAt: time.Now(), Metadata: map[string]string{ "cert_path": c.config.CertPath, "chain_path": c.config.ChainPath, "duration_ms": fmt.Sprintf("%d", dur.Milliseconds()), "idempotent": fmt.Sprintf("%t", res.SkippedAsIdempotent), }, }, nil } // ValidateOnly — Phase 5 real impl replacing the stub. 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("apachectl configtest (ValidateOnly): %w (output: %s)", err, string(out)) } return nil } // buildPlan — Apache assembles the same cert+chain+key Plan shape // as NGINX. Defaults follow Apache's distro conventions: // Debian/Ubuntu apache2 user, RHEL/CentOS apache user. func (c *Connector) buildPlan(request target.DeploymentRequest) deploy.Plan { files := []deploy.File{{ Path: c.config.CertPath, Bytes: []byte(request.CertPEM), 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, Owner: c.config.ChainFileOwner, Group: c.config.ChainFileGroup, }) } if c.config.KeyPath != "" && request.KeyPEM != "" { // Key file default mode is 0600 (owner-only read) — locked // down even when no override + destination doesn't exist. // FileDefaults.Mode (0644 — for cert/chain) does NOT apply // to keys; per-File explicit mode wins over Defaults. keyMode := c.config.KeyFileMode if keyMode == 0 { keyMode = 0600 } files = append(files, deploy.File{ Path: c.config.KeyPath, Bytes: []byte(request.KeyPEM), Mode: keyMode, Owner: c.config.KeyFileOwner, Group: c.config.KeyFileGroup, }) } return deploy.Plan{ Files: files, Defaults: deploy.FileDefaults{ Mode: 0644, Owner: pickFirstExistingUser("apache", "www-data", "httpd"), Group: pickFirstExistingGroup("apache", "www-data", "httpd"), }, BackupRetention: c.config.BackupRetention, } } // runPostDeployVerify mirrors the NGINX implementation; we don't // share via package because the per-connector retry knobs differ. func (c *Connector) runPostDeployVerify(ctx context.Context, deployedCertPEM string) error { verify := c.config.PostDeployVerify if verify != nil && !verify.Enabled { c.logger.Info("post-deploy TLS verify disabled per config") 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", "hint", "set Config.PostDeployVerify.Endpoint = host:port") return nil } want, err := certPEMToFingerprint(deployedCertPEM) if err != nil { return fmt.Errorf("compute deployed cert fingerprint: %w", err) } retryCfg := tlsprobe.RetryConfig{ Attempts: c.config.PostDeployVerifyAttempts, InitialBackoff: c.config.PostDeployVerifyBackoff, MaxBackoff: c.config.PostDeployVerifyMaxBackoff, } probe := func(probectx context.Context) error { res := c.probe(probectx, endpoint, timeout) if !res.Success { return fmt.Errorf("TLS probe failed: %s", res.Error) } got := strings.ToLower(res.Fingerprint) wantLower := strings.ToLower(want) if got != wantLower { return fmt.Errorf("post-deploy TLS verify SHA-256 mismatch: got %s, want %s", got, wantLower) } c.logger.Info("post-deploy TLS verify succeeded", "endpoint", endpoint, "fingerprint", got) return nil } return tlsprobe.VerifyWithExponentialBackoff(ctx, retryCfg, probe) } 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 "" } // ValidateDeployment — preserved from pre-Phase-5; switched to use // the test seam runValidate so tests don't need apachectl on PATH. 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() if _, err := c.runValidate(ctx, c.config.ValidateCommand); err != nil { errMsg := fmt.Sprintf("Apache config validation failed: %v", err) 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) } 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) } dur := time.Since(startTime) c.logger.Info("Apache deployment validated successfully", "duration", dur.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", dur.Milliseconds()), }, }, nil }