Files
certctl/internal/connector/target/traefik/traefik.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

209 lines
6.9 KiB
Go

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
}