feat(traefik,caddy,envoy,postfix): atomic deploy + post-deploy TLS verify + rollback + ValidateOnly

Phase 7 of the deploy-hardening I master bundle. Retrofits the
remaining file-based connectors against the canonical NGINX template.
Per-connector quirks codified:

- Postfix/Dovecot: full retrofit with PreCommit (postfix check /
  doveconf -n) + PostCommit (postfix reload / doveadm reload) +
  post-deploy TLS verify. Quirk preserved: when ChainPath is empty,
  chain is appended to cert (Postfix/Dovecot's "no separate chain"
  mode). Per-distro user defaults: postfix, dovecot, _postfix.
  Default key mode 0600. ValidateOnly real impl returns sentinel
  when no ValidateCommand.

- Traefik: simpler retrofit — no PreCommit/PostCommit because
  Traefik watches the cert directory via inotify and auto-reloads.
  Atomic-write via deploy.AtomicWriteFile + post-deploy TLS verify
  + cert rollback on verify mismatch. Default key mode 0600.
  ValidateOnly returns sentinel (no validate-with-the-target
  command exists for Traefik).

- Caddy: retrofitted both modes. File mode replaces os.WriteFile
  with deploy.AtomicWriteFile (preserves the file watcher's auto-
  reload). API mode unchanged (POST /load already atomic at the
  Caddy admin server). ValidateOnly real impl: API mode probes
  the admin /config/ endpoint to confirm Caddy is reachable;
  file mode returns sentinel.

- Envoy: file mode atomic-write via deploy.AtomicWriteFile.
  Envoy's SDS file watcher picks up the rename atomically without
  config reload. ValidateOnly returns sentinel (no Envoy CLI
  validate command exists for individual cert files).

Test counts (all packages above the prompt's >=20 bar):
- Postfix: 30 (12 new in postfix_atomic_test.go + 18 pre-existing)
- Traefik: 22 (12 new in traefik_atomic_test.go + 10 pre-existing)
- Caddy: 22 (10 new in caddy_atomic_test.go + 12 pre-existing)
- Envoy: 21 (5 new in envoy_atomic_test.go + 16 pre-existing)

Coverage: each connector at the prompt's >=80% target. golangci-lint
v2.11.4 clean across all 4 connector packages.

Smoke test connectorsAtPhase3 list shrunk from 10 to 6 entries
(postfix removed alongside nginx + apache + haproxy; traefik /
caddy / envoy retain their stubs in the list because their
ValidateOnly returns the sentinel for V2 — the real implementation
arrives only when there's a meaningful validate-with-the-target
command).

Wait — actually the smoke test still pins all 4 because their
ValidateOnly returns the sentinel. Postfix's real impl returns nil
on success (when ValidateCommand is set), so postfix MUST be
removed. Caddy's API mode is real-impl. Traefik + Envoy still
return sentinel always — they stay in the smoke list.

Phase 8 next: F5 + IIS — explicit post-deploy TLS verify +
on-failure rollback. Both already have transactional semantics
internally; the Phase 8 work is making rollback explicit + adding
the post-deploy verify.
This commit is contained in:
claude
2026-04-30 15:12:11 +00:00
parent 463590d02c
commit 758dbb283f
12 changed files with 1289 additions and 353 deletions
+215 -115
View File
@@ -1,208 +1,308 @@
// Package traefik implements the Traefik file-provider target
// connector. As of deploy-hardening I Phase 7: atomic-write via
// internal/deploy.AtomicWriteFile + optional post-deploy TLS
// verify. No PreCommit/PostCommit because Traefik watches the
// directory via inotify and auto-reloads on file change.
package traefik
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/deploy"
"github.com/shankar0123/certctl/internal/tlsprobe"
)
// 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)
CertDir string `json:"cert_dir"`
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
// Phase 7: per-file mode/owner overrides + post-deploy verify
// + backup retention.
CertFileMode os.FileMode `json:"cert_file_mode,omitempty"`
KeyFileMode os.FileMode `json:"key_file_mode,omitempty"`
CertFileOwner string `json:"cert_file_owner,omitempty"`
CertFileGroup string `json:"cert_file_group,omitempty"`
KeyFileOwner string `json:"key_file_owner,omitempty"`
KeyFileGroup string `json:"key_file_group,omitempty"`
PostDeployVerify *PostDeployVerifyConfig `json:"post_deploy_verify,omitempty"`
PostDeployVerifyAttempts int `json:"post_deploy_verify_attempts,omitempty"`
PostDeployVerifyBackoff time.Duration `json:"post_deploy_verify_backoff,omitempty"`
BackupRetention int `json:"backup_retention,omitempty"`
}
type PostDeployVerifyConfig struct {
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint,omitempty"`
Timeout time.Duration `json:"timeout,omitempty"`
}
// 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
probe func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult
}
// 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,
}
return &Connector{config: config, logger: logger, probe: tlsprobe.ProbeTLS}
}
func (c *Connector) SetTestProbe(fn func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult) {
c.probe = fn
}
// 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
c.logger.Info("validating Traefik configuration", "cert_dir", cfg.CertDir,
"cert_file", cfg.CertFile, "key_file", cfg.KeyFile)
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
// DeployCertificate writes cert + chain (combined) and key as
// separate files via deploy.AtomicWriteFile. Traefik's inotify
// watcher picks up the changes and auto-reloads. Post-deploy
// verify (if enabled) handshakes against the configured endpoint.
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)
"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"
// Preserve the pre-Phase-7 trailing-newline convention so
// existing operator deploys + tests don't break on byte-equal
// comparisons.
combined := request.CertPEM + "\n"
if request.ChainPEM != "" {
certData += request.ChainPEM + "\n"
combined = combined + 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)
certMode := c.config.CertFileMode
if certMode == 0 {
certMode = 0644
}
keyMode := c.config.KeyFileMode
if keyMode == 0 {
keyMode = 0600
}
// Write private key with secure permissions (0600: rw-------)
certRes, err := deploy.AtomicWriteFile(ctx, certPath, []byte(combined), deploy.WriteOptions{
Mode: certMode, Owner: c.config.CertFileOwner, Group: c.config.CertFileGroup,
BackupRetention: c.config.BackupRetention,
})
if err != nil {
return c.failureResult(certPath, "write cert", err, startTime), err
}
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)
_, err := deploy.AtomicWriteFile(ctx, keyPath, []byte(request.KeyPEM), deploy.WriteOptions{
Mode: keyMode, Owner: c.config.KeyFileOwner, Group: c.config.KeyFileGroup,
BackupRetention: c.config.BackupRetention,
})
if err != nil {
// Cert already written; try to roll back the cert too.
if certRes.BackupPath != "" {
if bytes, rErr := os.ReadFile(certRes.BackupPath); rErr == nil {
_, _ = deploy.AtomicWriteFile(ctx, certPath, bytes, deploy.WriteOptions{SkipIdempotent: true, BackupRetention: -1})
}
}
return c.failureResult(keyPath, "write key", err, startTime), err
}
}
deploymentDuration := time.Since(startTime)
c.logger.Info("certificate deployed to Traefik successfully",
"duration", deploymentDuration.String(),
"cert_path", certPath,
"key_path", keyPath)
// Post-deploy TLS verify.
if !certRes.Idempotent {
if vErr := c.runPostDeployVerify(ctx, request.CertPEM); vErr != nil {
c.logger.Error("post-deploy TLS verify failed; rolling back", "error", vErr)
rbErr := c.rollbackCertAndKey(ctx, certPath, certRes.BackupPath, keyPath)
if rbErr != nil {
return c.failureResult(certPath, "verify+rollback both failed",
fmt.Errorf("verify: %w; rollback: %v", vErr, rbErr), startTime), rbErr
}
return c.failureResult(certPath, "post-deploy verify failed; rolled back", vErr, startTime), vErr
}
}
dur := time.Since(startTime)
idemNote := ""
if certRes.Idempotent {
idemNote = " (idempotent skip — bytes unchanged)"
}
c.logger.Info("certificate deployed to Traefik successfully",
"duration", dur.String(), "cert_path", certPath, "idempotent", certRes.Idempotent)
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)",
Message: "Certificate deployed to Traefik (file watcher will auto-reload)" + idemNote,
DeployedAt: time.Now(),
Metadata: map[string]string{
"cert_path": certPath,
"key_path": keyPath,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
"cert_path": certPath, "key_path": keyPath,
"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
"idempotent": fmt.Sprintf("%t", certRes.Idempotent),
},
}, 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
// ValidateOnly returns ErrValidateOnlyNotSupported. Traefik has no
// validate-with-the-target command (the file watcher just picks up
// changes); there is no way to dry-run a cert deploy without
// touching the live files.
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
return target.ErrValidateOnlyNotSupported
}
func (c *Connector) runPostDeployVerify(ctx context.Context, deployedCertPEM string) error {
verify := c.config.PostDeployVerify
if verify == nil || !verify.Enabled || verify.Endpoint == "" {
return nil
}
timeout := verify.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
want, err := certPEMToFingerprint(deployedCertPEM)
if err != nil {
return fmt.Errorf("compute fingerprint: %w", err)
}
attempts := c.config.PostDeployVerifyAttempts
if attempts <= 0 {
attempts = 3
}
backoff := c.config.PostDeployVerifyBackoff
if backoff <= 0 {
backoff = 2 * time.Second
}
var lastErr error
for i := 0; i < attempts; i++ {
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
}
res := c.probe(ctx, verify.Endpoint, timeout)
if !res.Success {
lastErr = fmt.Errorf("TLS probe failed: %s", res.Error)
continue
}
if strings.EqualFold(res.Fingerprint, want) {
return nil
}
lastErr = fmt.Errorf("post-deploy TLS verify SHA-256 mismatch: got %s, want %s", res.Fingerprint, want)
}
return lastErr
}
func (c *Connector) rollbackCertAndKey(ctx context.Context, certPath, certBackup, keyPath string) error {
if certBackup == "" {
if err := os.Remove(certPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
} else {
bytes, err := os.ReadFile(certBackup)
if err != nil {
return fmt.Errorf("read cert backup: %w", err)
}
if _, err := deploy.AtomicWriteFile(ctx, certPath, bytes, deploy.WriteOptions{SkipIdempotent: true, BackupRetention: -1}); err != nil {
return err
}
}
return nil
}
func (c *Connector) failureResult(addr, stage string, err error, startTime time.Time) *target.DeploymentResult {
return &target.DeploymentResult{
Success: false, TargetAddress: addr,
Message: fmt.Sprintf("%s: %v", stage, err), DeployedAt: time.Now(),
Metadata: map[string]string{
"stage": stage, "duration_ms": fmt.Sprintf("%d", time.Since(startTime).Milliseconds()),
},
}
}
func certPEMToFingerprint(pemBytes string) (string, error) {
begin := "-----BEGIN CERTIFICATE-----"
end := "-----END CERTIFICATE-----"
beginIdx := strings.Index(pemBytes, begin)
if beginIdx < 0 {
return "", fmt.Errorf("no CERTIFICATE PEM block")
}
rest := pemBytes[beginIdx+len(begin):]
endIdx := strings.Index(rest, end)
if endIdx < 0 {
return "", fmt.Errorf("PEM not terminated")
}
body := strings.TrimSpace(rest[:endIdx])
body = strings.ReplaceAll(body, "\n", "")
body = strings.ReplaceAll(body, "\r", "")
body = strings.ReplaceAll(body, " ", "")
der, err := base64.StdEncoding.DecodeString(body)
if err != nil {
return "", fmt.Errorf("base64: %w", err)
}
h := sha256.Sum256(der)
return hex.EncodeToString(h[:]), nil
}
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)
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)
Valid: false, Serial: request.Serial, TargetAddress: certPath,
Message: fmt.Sprintf("certificate file not found: %s", certPath), ValidatedAt: time.Now(),
}, fmt.Errorf("certificate file not found: %s", certPath)
}
// 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)
Valid: false, Serial: request.Serial, TargetAddress: keyPath,
Message: fmt.Sprintf("private key file not found: %s", keyPath), ValidatedAt: time.Now(),
}, fmt.Errorf("private key file not found: %s", keyPath)
}
validationDuration := time.Since(startTime)
c.logger.Info("Traefik deployment validated successfully",
"duration", validationDuration.String())
dur := time.Since(startTime)
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: certPath,
Message: "Certificate and key files accessible",
ValidatedAt: time.Now(),
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()),
"cert_path": certPath, "key_path": keyPath,
"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
},
}, nil
}
@@ -0,0 +1,214 @@
package traefik_test
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/traefik"
"github.com/shankar0123/certctl/internal/deploy"
"github.com/shankar0123/certctl/internal/tlsprobe"
)
// Phase 7 of the deploy-hardening I master bundle: atomic + verify
// for Traefik. No reload command (Traefik watches via inotify);
// post-deploy TLS verify is the load-bearing safety check.
const certA = "-----BEGIN CERTIFICATE-----\nQUxQSEEtQ0VSVA==\n-----END CERTIFICATE-----\n"
const keyA = "-----BEGIN PRIVATE KEY-----\nZmFrZS1rZXk=\n-----END PRIVATE KEY-----\n"
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.NewFile(0, os.DevNull), &slog.HandlerOptions{Level: slog.LevelError}))
}
func fingerprintOfPEM(pem string) string {
beg := strings.Index(pem, "-----BEGIN CERTIFICATE-----") + len("-----BEGIN CERTIFICATE-----")
body := pem[beg:]
end := strings.Index(body, "-----END CERTIFICATE-----")
body = strings.TrimSpace(body[:end])
body = strings.ReplaceAll(body, "\n", "")
der, _ := base64.StdEncoding.DecodeString(body)
h := sha256.Sum256(der)
return hex.EncodeToString(h[:])
}
func newC(_ *testing.T, dir string) *traefik.Connector {
c := traefik.New(&traefik.Config{
CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem",
}, quietLogger())
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Success: true, Fingerprint: "x"}
})
return c
}
func TestTraefik_Atomic_Happy(t *testing.T) {
dir := t.TempDir()
c := newC(t, dir)
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA})
if err != nil || !res.Success {
t.Fatal(err)
}
}
func TestTraefik_Atomic_VerifyMatch(t *testing.T) {
dir := t.TempDir()
c := traefik.New(&traefik.Config{
CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &traefik.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
}, quietLogger())
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Success: true, Fingerprint: fingerprintOfPEM(certA)}
})
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil || !res.Success {
t.Fatal(err)
}
}
func TestTraefik_Atomic_VerifyMismatch_Rollback(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("OLD\n"), 0644)
c := traefik.New(&traefik.Config{
CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &traefik.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
}, quietLogger())
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Success: true, Fingerprint: "0000"}
})
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected mismatch error")
}
if got, _ := os.ReadFile(cert); string(got) != "OLD\n" {
t.Errorf("cert after rollback = %q, want OLD", got)
}
}
func TestTraefik_Atomic_VerifyDialTimeout(t *testing.T) {
dir := t.TempDir()
c := traefik.New(&traefik.Config{
CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &traefik.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
}, quietLogger())
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Success: false, Error: "timeout"}
})
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected timeout")
}
}
func TestTraefik_Atomic_Idempotency(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte(certA+"\n"), 0644)
c := newC(t, dir)
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil || !res.Success {
t.Fatal(err)
}
if res.Metadata["idempotent"] != "true" {
t.Errorf("idempotent flag = %q", res.Metadata["idempotent"])
}
}
func TestTraefik_Atomic_DefaultKeyMode_0600(t *testing.T) {
dir := t.TempDir()
c := newC(t, dir)
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA})
stat, _ := os.Stat(filepath.Join(dir, "key.pem"))
if stat.Mode().Perm() != 0600 {
t.Errorf("key mode = %#o", stat.Mode().Perm())
}
}
func TestTraefik_Atomic_KeyModeOverride(t *testing.T) {
dir := t.TempDir()
c := traefik.New(&traefik.Config{
CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem", KeyFileMode: 0640,
}, quietLogger())
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Success: true, Fingerprint: "x"}
})
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA})
stat, _ := os.Stat(filepath.Join(dir, "key.pem"))
if stat.Mode().Perm() != 0640 {
t.Errorf("key mode = %#o", stat.Mode().Perm())
}
}
func TestTraefik_Atomic_BackupCreated(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("OLD"), 0644)
c := newC(t, dir)
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
entries, _ := os.ReadDir(dir)
found := false
for _, e := range entries {
if strings.Contains(e.Name(), deploy.BackupSuffix) {
found = true
}
}
if !found {
t.Error("no backup")
}
}
func TestTraefik_Atomic_NoChain(t *testing.T) {
dir := t.TempDir()
c := newC(t, dir)
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil || !res.Success {
t.Fatal(err)
}
}
func TestTraefik_Atomic_NoKey(t *testing.T) {
dir := t.TempDir()
c := newC(t, dir)
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if _, err := os.Stat(filepath.Join(dir, "key.pem")); err == nil {
t.Error("key written despite empty KeyPEM")
}
}
func TestTraefik_ValidateOnly_Sentinel(t *testing.T) {
c := newC(t, t.TempDir())
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("got %v", err)
}
}
func TestTraefik_Atomic_VerifyDisabled(t *testing.T) {
dir := t.TempDir()
c := traefik.New(&traefik.Config{
CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem",
PostDeployVerify: &traefik.PostDeployVerifyConfig{Enabled: false, Endpoint: "h:443"},
}, quietLogger())
var n int32
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
atomic.AddInt32(&n, 1)
return tlsprobe.ProbeResult{Success: true, Fingerprint: "x"}
})
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if n != 0 {
t.Errorf("probe called %d times despite Enabled=false", n)
}
}
@@ -1,18 +0,0 @@
package traefik
import (
"context"
"github.com/shankar0123/certctl/internal/connector/target"
)
// ValidateOnly is the default Phase 3 stub for the deploy-hardening
// I master bundle: returns ErrValidateOnlyNotSupported so existing
// connectors compile against the extended target.Connector interface
// without changing behavior. Phase traefik dry-run support arrives when
// the connector's atomic-deploy implementation lands (NGINX in
// Phase 4, Apache in Phase 5, etc.); each phase replaces this stub
// with a real validate-with-the-target implementation.
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
return target.ErrValidateOnlyNotSupported
}