mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 20:29:04 +00:00
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>
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user