mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
be72627aeb
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>
304 lines
9.8 KiB
Go
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
|
|
}
|