Files
certctl/internal/connector/target/caddy/caddy.go
T
shankar0123 be72627aeb feat: M25 post-deployment TLS verification + M26 Traefik/Caddy targets
M25: After deploying a certificate, the agent probes the live TLS
endpoint and compares SHA-256 fingerprints to verify the correct cert
is being served. Best-effort — failures don't block deployments.
New endpoints: POST /jobs/{id}/verify, GET /jobs/{id}/verification.
Migration 000008 adds verification columns to jobs table.

M26: Traefik target connector (file provider, auto-reload) and Caddy
target connector (dual-mode: admin API hot-reload or file-based).
Both wired into agent dispatch.

Also: restructured README to highlight supported integrations (issuers,
targets, notifiers) earlier, moved API/CLI/MCP sections lower. Updated
all docs (features, connectors, architecture, testing guide, why-certctl)
and fixed integration tests for 18-param RegisterHandlers signature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 21:07:16 -04:00

304 lines
9.8 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"
)
// 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
certData := request.CertPEM + "\n"
if request.ChainPEM != "" {
certData += request.ChainPEM + "\n"
}
if err := os.WriteFile(certPath, []byte(certData), 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
if request.KeyPEM != "" {
if err := os.WriteFile(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: 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
}