mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 14:28:51 +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,208 @@
|
||||
package traefik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Config represents the Traefik deployment target configuration.
|
||||
// Traefik uses a file provider that watches a directory for certificate files.
|
||||
// When files change, Traefik automatically reloads without requiring a reload command.
|
||||
type Config struct {
|
||||
CertDir string `json:"cert_dir"` // Directory where Traefik watches for certificate files
|
||||
CertFile string `json:"cert_file"` // Filename for certificate (default: cert.pem)
|
||||
KeyFile string `json:"key_file"` // Filename for private key (default: key.pem)
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Traefik servers.
|
||||
// This connector runs on the AGENT side and handles local certificate deployment.
|
||||
// Traefik watches the configured directory and automatically reloads when files change.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Traefik target connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the certificate directory exists and is writable.
|
||||
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 Traefik config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.CertDir == "" {
|
||||
return fmt.Errorf("Traefik cert_dir is required")
|
||||
}
|
||||
|
||||
// Default filenames if not provided
|
||||
if cfg.CertFile == "" {
|
||||
cfg.CertFile = "cert.pem"
|
||||
}
|
||||
if cfg.KeyFile == "" {
|
||||
cfg.KeyFile = "key.pem"
|
||||
}
|
||||
|
||||
c.logger.Info("validating Traefik configuration",
|
||||
"cert_dir", cfg.CertDir,
|
||||
"cert_file", cfg.CertFile,
|
||||
"key_file", cfg.KeyFile)
|
||||
|
||||
// Verify directory exists and is writable
|
||||
if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("Traefik cert directory does not exist: %s", cfg.CertDir)
|
||||
}
|
||||
|
||||
// Try to write a test file to verify directory is writable
|
||||
testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
return fmt.Errorf("Traefik cert directory is not writable: %s (%w)", cfg.CertDir, err)
|
||||
}
|
||||
// Clean up test file
|
||||
os.Remove(testFile)
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("Traefik configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate writes the certificate and key files to the configured directory.
|
||||
// Traefik watches this directory and automatically reloads when files change.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Write certificate to cert_file with mode 0644 (readable by all)
|
||||
// 2. Write private key to key_file with mode 0600 (private key permissions)
|
||||
// 3. Traefik's file watcher automatically picks up the changes
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to Traefik",
|
||||
"cert_dir", c.config.CertDir,
|
||||
"cert_file", c.config.CertFile,
|
||||
"key_file", c.config.KeyFile)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
|
||||
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
|
||||
|
||||
// Write certificate and chain combined with mode 0644 (readable by all)
|
||||
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 with secure permissions (0600: rw-------)
|
||||
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 Traefik successfully",
|
||||
"duration", deploymentDuration.String(),
|
||||
"cert_path", certPath,
|
||||
"key_path", keyPath)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: certPath,
|
||||
DeploymentID: fmt.Sprintf("traefik-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployed to Traefik (file watcher will auto-reload)",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"cert_path": certPath,
|
||||
"key_path": keyPath,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the deployed certificate files are readable.
|
||||
// It checks that both the certificate and key files exist and are accessible.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Verify certificate file exists and is readable
|
||||
// 2. Verify key file exists and is readable
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating Traefik deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
|
||||
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
|
||||
|
||||
// Verify certificate file exists and is readable
|
||||
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)
|
||||
}
|
||||
|
||||
// Verify key file exists and is readable
|
||||
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("Traefik deployment validated successfully",
|
||||
"duration", validationDuration.String())
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: certPath,
|
||||
Message: "Certificate and key files accessible",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"cert_path": certPath,
|
||||
"key_path": keyPath,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package traefik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/traefik"
|
||||
)
|
||||
|
||||
func TestTraefikConnector_ValidateConfig_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_ValidateConfig_InvalidJSON(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
connector := traefik.New(&traefik.Config{}, logger)
|
||||
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_ValidateConfig_MissingCertDir(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := traefik.Config{
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert_dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_ValidateConfig_DirectoryNotExists(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := traefik.Config{
|
||||
CertDir: "/nonexistent/directory",
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_DeployCertificate_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify certificate file was created
|
||||
certPath := filepath.Join(tmpDir, "cert.pem")
|
||||
if _, err := os.Stat(certPath); os.IsNotExist(err) {
|
||||
t.Fatalf("certificate file was not created: %s", certPath)
|
||||
}
|
||||
|
||||
// Verify key file was created with correct permissions
|
||||
keyPath := filepath.Join(tmpDir, "key.pem")
|
||||
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||
t.Fatalf("key file was not created: %s", keyPath)
|
||||
}
|
||||
|
||||
// Check key file permissions (should be 0600)
|
||||
keyInfo, _ := os.Stat(keyPath)
|
||||
perms := keyInfo.Mode().Perm()
|
||||
if perms != 0600 {
|
||||
t.Fatalf("key file permissions are %o, expected 0600", perms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_DeployCertificate_WriteError(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
// Use a non-existent directory to trigger write error
|
||||
cfg := traefik.Config{
|
||||
CertDir: "/root/certctl/certs",
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for write failure")
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
t.Fatal("deployment should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_ValidateDeployment_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
// First deploy a certificate
|
||||
deployRequest := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
connector.DeployCertificate(ctx, deployRequest)
|
||||
|
||||
// Now validate
|
||||
validateRequest := target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
}
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, validateRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
t.Fatalf("validation should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
if result.Serial != "123456" {
|
||||
t.Fatalf("serial mismatch: expected 123456, got %s", result.Serial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_ValidateDeployment_CertFileNotFound(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
// Don't deploy anything, just validate
|
||||
validateRequest := target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
}
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, validateRequest)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing certificate file")
|
||||
}
|
||||
|
||||
if result.Valid {
|
||||
t.Fatal("validation should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_DeployCertificate_WithoutChain(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
// Deploy without chain
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify certificate file exists
|
||||
certPath := filepath.Join(tmpDir, "cert.pem")
|
||||
data, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read cert file: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----\n" {
|
||||
t.Fatalf("certificate content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_DefaultFilenames(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
// Don't specify CertFile and KeyFile, use defaults
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user