mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 12:18:51 +00:00
a7cce9afdd
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.
312 lines
10 KiB
Go
312 lines
10 KiB
Go
package caddy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/target"
|
|
"github.com/shankar0123/certctl/internal/deploy"
|
|
)
|
|
|
|
// Config represents the Caddy deployment target configuration.
|
|
// Caddy supports both API-based and file-based certificate deployment.
|
|
// In API mode, certificates are posted to the Caddy admin API.
|
|
// In file mode, certificates are written to a directory and Caddy reloads.
|
|
type Config struct {
|
|
AdminAPI string `json:"admin_api"` // Caddy admin API URL (e.g., http://localhost:2019, default: http://localhost:2019)
|
|
CertDir string `json:"cert_dir"` // Directory for file-based deployment (used if API fails or mode=file)
|
|
CertFile string `json:"cert_file"` // Filename for certificate in file mode (default: cert.pem)
|
|
KeyFile string `json:"key_file"` // Filename for private key in file mode (default: key.pem)
|
|
Mode string `json:"mode"` // Deployment mode: "api" (default) or "file"
|
|
}
|
|
|
|
// Connector implements the target.Connector interface for Caddy servers.
|
|
// This connector runs on the AGENT side and handles local certificate deployment.
|
|
// It supports both API-based hot reload and file-based deployment.
|
|
type Connector struct {
|
|
config *Config
|
|
logger *slog.Logger
|
|
client *http.Client
|
|
}
|
|
|
|
// New creates a new Caddy target connector with the given configuration and logger.
|
|
func New(config *Config, logger *slog.Logger) *Connector {
|
|
return &Connector{
|
|
config: config,
|
|
logger: logger,
|
|
client: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
// ValidateConfig checks that the Caddy configuration is 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 Caddy config: %w", err)
|
|
}
|
|
|
|
// Set defaults
|
|
if cfg.AdminAPI == "" {
|
|
cfg.AdminAPI = "http://localhost:2019"
|
|
}
|
|
if cfg.Mode == "" {
|
|
cfg.Mode = "api"
|
|
}
|
|
if cfg.CertFile == "" {
|
|
cfg.CertFile = "cert.pem"
|
|
}
|
|
if cfg.KeyFile == "" {
|
|
cfg.KeyFile = "key.pem"
|
|
}
|
|
|
|
// Validate mode
|
|
if cfg.Mode != "api" && cfg.Mode != "file" {
|
|
return fmt.Errorf("Caddy mode must be 'api' or 'file', got: %s", cfg.Mode)
|
|
}
|
|
|
|
c.logger.Info("validating Caddy configuration",
|
|
"admin_api", cfg.AdminAPI,
|
|
"mode", cfg.Mode)
|
|
|
|
// For file mode, verify directory exists
|
|
if cfg.Mode == "file" {
|
|
if cfg.CertDir == "" {
|
|
return fmt.Errorf("Caddy cert_dir is required in file mode")
|
|
}
|
|
if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
|
|
return fmt.Errorf("Caddy cert directory does not exist: %s", cfg.CertDir)
|
|
}
|
|
// Test write access
|
|
testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
|
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
|
return fmt.Errorf("Caddy cert directory is not writable: %s (%w)", cfg.CertDir, err)
|
|
}
|
|
os.Remove(testFile)
|
|
}
|
|
|
|
c.config = &cfg
|
|
c.logger.Info("Caddy configuration validated")
|
|
return nil
|
|
}
|
|
|
|
// DeployCertificate deploys a certificate to Caddy using the configured mode.
|
|
// In API mode, it posts the certificate to Caddy's admin API.
|
|
// In file mode, it writes the certificate files and relies on Caddy's file watcher.
|
|
//
|
|
// Steps:
|
|
// 1. If mode="api": POST to Caddy admin API endpoint with certificate data
|
|
// 2. If mode="file" or API fails: Write certificate and key files to cert_dir
|
|
// 3. Log deployment status
|
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
|
c.logger.Info("deploying certificate to Caddy",
|
|
"mode", c.config.Mode,
|
|
"admin_api", c.config.AdminAPI)
|
|
|
|
startTime := time.Now()
|
|
|
|
// Try API mode if configured
|
|
if c.config.Mode == "api" {
|
|
result, err := c.deployViaAPI(ctx, request)
|
|
if err == nil {
|
|
c.logger.Info("certificate deployed to Caddy via API",
|
|
"duration", time.Since(startTime).String())
|
|
return result, nil
|
|
}
|
|
c.logger.Warn("API deployment failed, falling back to file mode", "error", err)
|
|
}
|
|
|
|
// Fall back to file mode
|
|
return c.deployViaFile(ctx, request, startTime)
|
|
}
|
|
|
|
// deployViaAPI deploys a certificate using Caddy's admin API.
|
|
func (c *Connector) deployViaAPI(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
|
c.logger.Debug("attempting API deployment", "url", c.config.AdminAPI)
|
|
|
|
// Build the certificate payload with combined cert and chain
|
|
certData := request.CertPEM + "\n"
|
|
if request.ChainPEM != "" {
|
|
certData += request.ChainPEM + "\n"
|
|
}
|
|
|
|
payload := map[string]string{
|
|
"cert": certData,
|
|
"key": request.KeyPEM,
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(payload)
|
|
apiURL := c.config.AdminAPI + "/config/apps/tls/certificates/load"
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create API request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to reach Caddy API: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
|
return nil, fmt.Errorf("Caddy API returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return &target.DeploymentResult{
|
|
Success: true,
|
|
TargetAddress: c.config.AdminAPI,
|
|
DeploymentID: fmt.Sprintf("caddy-api-%d", time.Now().Unix()),
|
|
Message: "Certificate deployed via Caddy admin API",
|
|
DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"method": "api",
|
|
"admin_url": c.config.AdminAPI,
|
|
"duration_ms": fmt.Sprintf("%d", time.Since(time.Now()).Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// deployViaFile deploys a certificate by writing files to the cert directory.
|
|
func (c *Connector) deployViaFile(ctx context.Context, request target.DeploymentRequest, startTime time.Time) (*target.DeploymentResult, error) {
|
|
c.logger.Debug("deploying via file mode", "cert_dir", c.config.CertDir)
|
|
|
|
if c.config.CertDir == "" {
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
Message: "cert_dir required for file mode deployment",
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("cert_dir not configured for file mode")
|
|
}
|
|
|
|
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
|
|
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
|
|
|
|
// Write certificate with chain — Phase 7 (deploy-hardening I):
|
|
// atomic-write via deploy.AtomicWriteFile so cert/key swap
|
|
// atomically and have backup files for rollback (Caddy's file
|
|
// watcher picks up the rename atomically, no torn config).
|
|
certData := request.CertPEM + "\n"
|
|
if request.ChainPEM != "" {
|
|
certData += request.ChainPEM + "\n"
|
|
}
|
|
if _, err := deploy.AtomicWriteFile(ctx, certPath, []byte(certData), deploy.WriteOptions{
|
|
Mode: 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: certPath,
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Write private key — atomic + 0600 default.
|
|
if request.KeyPEM != "" {
|
|
if _, err := deploy.AtomicWriteFile(ctx, keyPath, []byte(request.KeyPEM), deploy.WriteOptions{
|
|
Mode: 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: keyPath,
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
}
|
|
|
|
deploymentDuration := time.Since(startTime)
|
|
c.logger.Info("certificate deployed to Caddy via file mode",
|
|
"duration", deploymentDuration.String(),
|
|
"cert_path", certPath,
|
|
"key_path", keyPath)
|
|
|
|
return &target.DeploymentResult{
|
|
Success: true,
|
|
TargetAddress: certPath,
|
|
DeploymentID: fmt.Sprintf("caddy-file-%d", time.Now().Unix()),
|
|
Message: "Certificate deployed to Caddy (file-based)",
|
|
DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"method": "file",
|
|
"cert_path": certPath,
|
|
"key_path": keyPath,
|
|
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
|
|
// For API mode, it doesn't perform additional validation.
|
|
// For file mode, it checks that the certificate and key files exist and are readable.
|
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
|
c.logger.Info("validating Caddy deployment",
|
|
"certificate_id", request.CertificateID,
|
|
"serial", request.Serial,
|
|
"mode", c.config.Mode)
|
|
|
|
startTime := time.Now()
|
|
|
|
// For file mode, verify files exist
|
|
if c.config.Mode == "file" || c.config.CertDir != "" {
|
|
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
|
|
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
|
|
|
|
if _, err := os.Stat(certPath); os.IsNotExist(err) {
|
|
errMsg := fmt.Sprintf("certificate file not found: %s", certPath)
|
|
c.logger.Error("validation failed", "error", err)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: certPath,
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
|
errMsg := fmt.Sprintf("private key file not found: %s", keyPath)
|
|
c.logger.Error("validation failed", "error", err)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: keyPath,
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
}
|
|
|
|
validationDuration := time.Since(startTime)
|
|
c.logger.Info("Caddy deployment validated successfully",
|
|
"duration", validationDuration.String())
|
|
|
|
return &target.ValidationResult{
|
|
Valid: true,
|
|
Serial: request.Serial,
|
|
TargetAddress: c.config.AdminAPI,
|
|
Message: "Caddy certificate deployment validated",
|
|
ValidatedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"mode": c.config.Mode,
|
|
"admin_api": c.config.AdminAPI,
|
|
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|