diff --git a/internal/connector/target/caddy/caddy.go b/internal/connector/target/caddy/caddy.go index f6e39f1..7368eb8 100644 --- a/internal/connector/target/caddy/caddy.go +++ b/internal/connector/target/caddy/caddy.go @@ -13,6 +13,7 @@ import ( "time" "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/deploy" ) // Config represents the Caddy deployment target configuration. @@ -192,12 +193,17 @@ func (c *Connector) deployViaFile(ctx context.Context, request target.Deployment certPath := filepath.Join(c.config.CertDir, c.config.CertFile) keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile) - // Write certificate with chain + // Write certificate with chain — Phase 7 (deploy-hardening I): + // atomic-write via deploy.AtomicWriteFile so cert/key swap + // atomically and have backup files for rollback (Caddy's file + // watcher picks up the rename atomically, no torn config). certData := request.CertPEM + "\n" if request.ChainPEM != "" { certData += request.ChainPEM + "\n" } - if err := os.WriteFile(certPath, []byte(certData), 0644); err != nil { + if _, err := deploy.AtomicWriteFile(ctx, certPath, []byte(certData), deploy.WriteOptions{ + Mode: 0644, + }); err != nil { errMsg := fmt.Sprintf("failed to write certificate: %v", err) c.logger.Error("certificate deployment failed", "error", err) return &target.DeploymentResult{ @@ -208,9 +214,11 @@ func (c *Connector) deployViaFile(ctx context.Context, request target.Deployment }, fmt.Errorf("%s", errMsg) } - // Write private key + // Write private key — atomic + 0600 default. if request.KeyPEM != "" { - if err := os.WriteFile(keyPath, []byte(request.KeyPEM), 0600); err != nil { + if _, err := deploy.AtomicWriteFile(ctx, keyPath, []byte(request.KeyPEM), deploy.WriteOptions{ + Mode: 0600, + }); err != nil { errMsg := fmt.Sprintf("failed to write private key: %v", err) c.logger.Error("key deployment failed", "error", err) return &target.DeploymentResult{ diff --git a/internal/connector/target/caddy/caddy_atomic_test.go b/internal/connector/target/caddy/caddy_atomic_test.go new file mode 100644 index 0000000..d5e3adf --- /dev/null +++ b/internal/connector/target/caddy/caddy_atomic_test.go @@ -0,0 +1,154 @@ +package caddy_test + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/connector/target/caddy" + "github.com/shankar0123/certctl/internal/deploy" +) + +// Phase 7 of the deploy-hardening I master bundle: atomic-write + +// ValidateOnly real impl + (where applicable) post-deploy verify +// for Caddy's API + file modes. + +const certA = "-----BEGIN CERTIFICATE-----\nQUxQSEEtQ0VSVA==\n-----END CERTIFICATE-----\n" +const keyA = "-----BEGIN PRIVATE KEY-----\nZmFrZS1rZXk=\n-----END PRIVATE KEY-----\n" + +// newTestLogger returns a no-op slog logger so test runs stay readable. +func newTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.NewFile(0, os.DevNull), &slog.HandlerOptions{Level: slog.LevelError})) +} + +func TestCaddy_FileMode_AtomicWrite(t *testing.T) { + dir := t.TempDir() + cfg := caddy.Config{Mode: "file", CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem"} + c := caddy.New(&cfg, newTestLogger()) + res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA}) + if err != nil || !res.Success { + t.Fatal(err) + } + if got, _ := os.ReadFile(filepath.Join(dir, "cert.pem")); !strings.Contains(string(got), "BEGIN CERTIFICATE") { + t.Errorf("cert not written: %q", got) + } + if got, _ := os.ReadFile(filepath.Join(dir, "key.pem")); !strings.Contains(string(got), "BEGIN PRIVATE KEY") { + t.Errorf("key not written: %q", got) + } +} + +func TestCaddy_FileMode_BackupCreated(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + os.WriteFile(cert, []byte("OLD"), 0644) + cfg := caddy.Config{Mode: "file", CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem"} + c := caddy.New(&cfg, newTestLogger()) + 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 created") + } +} + +func TestCaddy_FileMode_KeyMode_0600(t *testing.T) { + dir := t.TempDir() + cfg := caddy.Config{Mode: "file", CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem"} + c := caddy.New(&cfg, newTestLogger()) + 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 TestCaddy_FileMode_Idempotency(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + os.WriteFile(cert, []byte(certA+"\n"), 0644) + cfg := caddy.Config{Mode: "file", CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem"} + c := caddy.New(&cfg, newTestLogger()) + c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}) + // Idempotent path: no backup created (only diff triggers backup). + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.Contains(e.Name(), deploy.BackupSuffix) { + t.Errorf("backup created on idempotent skip: %s", e.Name()) + } + } +} + +func TestCaddy_ValidateOnly_FileMode_ReturnsSentinel(t *testing.T) { + cfg := caddy.Config{Mode: "file", CertDir: t.TempDir(), CertFile: "cert.pem", KeyFile: "key.pem"} + c := caddy.New(&cfg, newTestLogger()) + if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) { + t.Errorf("got %v", err) + } +} + +func TestCaddy_ValidateOnly_APIMode_ProbesAdminAPI(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/config/" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + cfg := caddy.Config{Mode: "api", AdminAPI: srv.URL} + c := caddy.New(&cfg, newTestLogger()) + if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil { + t.Errorf("got %v, want nil", err) + } +} + +func TestCaddy_ValidateOnly_APIMode_AdminUnreachable(t *testing.T) { + cfg := caddy.Config{Mode: "api", AdminAPI: "http://localhost:9"} // closed port + c := caddy.New(&cfg, newTestLogger()) + if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err == nil { + t.Error("expected unreachable error") + } +} + +func TestCaddy_ValidateOnly_APIMode_AdminReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + cfg := caddy.Config{Mode: "api", AdminAPI: srv.URL} + c := caddy.New(&cfg, newTestLogger()) + if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err == nil { + t.Error("expected status-500 error") + } +} + +func TestCaddy_FileMode_NoKey(t *testing.T) { + dir := t.TempDir() + cfg := caddy.Config{Mode: "file", CertDir: dir, CertFile: "cert.pem", KeyFile: "key.pem"} + c := caddy.New(&cfg, newTestLogger()) + 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 TestCaddy_FileMode_BadDirError(t *testing.T) { + cfg := caddy.Config{Mode: "file", CertDir: "/nonexistent-xyz", CertFile: "cert.pem", KeyFile: "key.pem"} + c := caddy.New(&cfg, newTestLogger()) + _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}) + if err == nil { + t.Error("expected error on bad cert_dir") + } +} diff --git a/internal/connector/target/caddy/validate_only.go b/internal/connector/target/caddy/validate_only.go index 5791a8f..06f87fb 100644 --- a/internal/connector/target/caddy/validate_only.go +++ b/internal/connector/target/caddy/validate_only.go @@ -2,17 +2,37 @@ package caddy import ( "context" + "fmt" + "net/http" "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 caddy 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. +// ValidateOnly — Phase 7 (deploy-hardening I) replaces the stub +// with a real implementation: +// +// - api mode: probes the admin /config/ endpoint to confirm +// Caddy is reachable + responding. We don't simulate the cert +// load itself because Caddy's POST /load doesn't have a true +// dry-run flag. +// - file mode: no command-line cert validator exists for +// individual PEM files (Caddy validates them at load time). +// Returns ErrValidateOnlyNotSupported. func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error { + if c.config != nil && c.config.Mode == "api" && c.config.AdminAPI != "" { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.AdminAPI+"/config/", nil) + if err != nil { + return fmt.Errorf("ValidateOnly: build request: %w", err) + } + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("ValidateOnly: Caddy admin API unreachable: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("ValidateOnly: Caddy admin returned status %d", resp.StatusCode) + } + return nil + } return target.ErrValidateOnlyNotSupported } diff --git a/internal/connector/target/envoy/envoy.go b/internal/connector/target/envoy/envoy.go index f07f08d..f90fccb 100644 --- a/internal/connector/target/envoy/envoy.go +++ b/internal/connector/target/envoy/envoy.go @@ -11,6 +11,7 @@ import ( "time" "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/deploy" ) // Config represents the Envoy deployment target configuration. @@ -147,8 +148,11 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy certData += request.ChainPEM + "\n" } - // Write certificate with mode 0644 (readable by Envoy process) - if err := os.WriteFile(certPath, []byte(certData), 0644); err != nil { + // Phase 7 (deploy-hardening I): atomic-write via + // deploy.AtomicWriteFile so cert/key/chain swap atomically and + // have backup files for rollback. Envoy's SDS file watcher + // picks up the rename atomically — no torn config. + if _, err := deploy.AtomicWriteFile(ctx, certPath, []byte(certData), deploy.WriteOptions{Mode: 0644}); err != nil { errMsg := fmt.Sprintf("failed to write certificate: %v", err) c.logger.Error("certificate deployment failed", "error", err) return &target.DeploymentResult{ @@ -161,7 +165,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy // Write private key with secure permissions (0600: rw-------) if request.KeyPEM != "" { - if err := os.WriteFile(keyPath, []byte(request.KeyPEM), 0600); err != nil { + if _, err := deploy.AtomicWriteFile(ctx, keyPath, []byte(request.KeyPEM), deploy.WriteOptions{Mode: 0600}); err != nil { errMsg := fmt.Sprintf("failed to write private key: %v", err) c.logger.Error("key deployment failed", "error", err) return &target.DeploymentResult{ @@ -176,7 +180,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy // Write chain separately if chain_filename is configured if c.config.ChainFilename != "" && request.ChainPEM != "" { chainPath := filepath.Join(c.config.CertDir, c.config.ChainFilename) - if err := os.WriteFile(chainPath, []byte(request.ChainPEM+"\n"), 0644); err != nil { + if _, err := deploy.AtomicWriteFile(ctx, chainPath, []byte(request.ChainPEM+"\n"), deploy.WriteOptions{Mode: 0644}); err != nil { errMsg := fmt.Sprintf("failed to write chain: %v", err) c.logger.Error("chain deployment failed", "error", err) return &target.DeploymentResult{ diff --git a/internal/connector/target/envoy/envoy_atomic_test.go b/internal/connector/target/envoy/envoy_atomic_test.go new file mode 100644 index 0000000..57a8c5c --- /dev/null +++ b/internal/connector/target/envoy/envoy_atomic_test.go @@ -0,0 +1,95 @@ +package envoy_test + +import ( + "context" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/connector/target/envoy" + "github.com/shankar0123/certctl/internal/deploy" +) + +// Phase 7 of the deploy-hardening I master bundle: atomic-write +// retrofit for Envoy. Envoy file watcher (SDS) auto-reloads on +// rename, so the load-bearing change is the os.WriteFile -> +// deploy.AtomicWriteFile swap. + +const certA = "-----BEGIN CERTIFICATE-----\nQUxQSEEtQ0VSVA==\n-----END CERTIFICATE-----\n" +const keyA = "-----BEGIN PRIVATE KEY-----\nZmFrZS1rZXk=\n-----END PRIVATE KEY-----\n" + +func newTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.NewFile(0, os.DevNull), &slog.HandlerOptions{Level: slog.LevelError})) +} + +func TestEnvoy_Atomic_HappyPath(t *testing.T) { + dir := t.TempDir() + cfg := envoy.Config{CertDir: dir, CertFilename: "cert.pem", KeyFilename: "key.pem"} + c := envoy.New(&cfg, newTestLogger()) + res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA}) + if err != nil || !res.Success { + t.Fatal(err) + } + for _, p := range []string{filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")} { + if _, err := os.Stat(p); err != nil { + t.Errorf("file missing: %s", p) + } + } +} + +func TestEnvoy_Atomic_BackupCreated(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + os.WriteFile(cert, []byte("OLD"), 0644) + cfg := envoy.Config{CertDir: dir, CertFilename: "cert.pem", KeyFilename: "key.pem"} + c := envoy.New(&cfg, newTestLogger()) + 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 created") + } +} + +func TestEnvoy_Atomic_KeyMode_0600(t *testing.T) { + dir := t.TempDir() + cfg := envoy.Config{CertDir: dir, CertFilename: "cert.pem", KeyFilename: "key.pem"} + c := envoy.New(&cfg, newTestLogger()) + 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 TestEnvoy_Atomic_Idempotency(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + os.WriteFile(cert, []byte(certA+"\n"), 0644) + cfg := envoy.Config{CertDir: dir, CertFilename: "cert.pem", KeyFilename: "key.pem"} + c := envoy.New(&cfg, newTestLogger()) + c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}) + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.Contains(e.Name(), deploy.BackupSuffix) { + t.Errorf("backup created on idempotent skip: %s", e.Name()) + } + } +} + +func TestEnvoy_ValidateOnly_Sentinel(t *testing.T) { + cfg := envoy.Config{CertDir: t.TempDir(), CertFilename: "cert.pem", KeyFilename: "key.pem"} + c := envoy.New(&cfg, newTestLogger()) + if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) { + t.Errorf("got %v", err) + } +} diff --git a/internal/connector/target/nginx/nginx.go b/internal/connector/target/nginx/nginx.go index 65e8582..e4f1874 100644 --- a/internal/connector/target/nginx/nginx.go +++ b/internal/connector/target/nginx/nginx.go @@ -336,12 +336,20 @@ func (c *Connector) buildPlan(request target.DeploymentRequest) deploy.Plan { }) } if c.config.KeyPath != "" && request.KeyPEM != "" { + // Key file default mode is 0640 (NGINX worker reads via + // group); 0600 would lock the worker out unless the + // agent runs as the nginx user. Per-File explicit mode + // wins over Defaults; we set the default explicitly here + // so the deploy package's FileDefaults.Mode (0644 — for + // cert/chain) doesn't bleed onto the key. + keyMode := c.config.KeyFileMode + if keyMode == 0 { + keyMode = 0640 + } files = append(files, deploy.File{ Path: c.config.KeyPath, Bytes: []byte(request.KeyPEM), - // 0640 default for keys (NGINX worker reads via group); - // 0600 would lock the worker out. - Mode: c.config.KeyFileMode, + Mode: keyMode, Owner: c.config.KeyFileOwner, Group: c.config.KeyFileGroup, }) diff --git a/internal/connector/target/postfix/postfix.go b/internal/connector/target/postfix/postfix.go index 38f3171..c46fe57 100644 --- a/internal/connector/target/postfix/postfix.go +++ b/internal/connector/target/postfix/postfix.go @@ -1,55 +1,96 @@ +// Package postfix implements the Postfix + Dovecot mail-server +// target connector. As of the deploy-hardening I master bundle +// Phase 7, both modes follow the canonical NGINX template: +// atomic-write via internal/deploy.Apply, validate-with-the-target +// PreCommit, reload PostCommit, post-deploy TLS verify, rollback +// on failure. package postfix import ( "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "encoding/json" + "errors" "fmt" "log/slog" "os" "os/exec" + "os/user" "path/filepath" + "strings" "time" "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/deploy" + "github.com/shankar0123/certctl/internal/tlsprobe" "github.com/shankar0123/certctl/internal/validation" ) -// Config represents the Postfix/Dovecot deployment target configuration. -// This connector supports dual-mode operation: "postfix" for Postfix MTA -// and "dovecot" for Dovecot IMAP/POP3. The mode determines default file -// paths and reload commands. Both modes write cert/key/chain files and -// reload the mail service. type Config struct { - Mode string `json:"mode"` // "postfix" (default) or "dovecot" - CertPath string `json:"cert_path"` // Path where cert will be written - KeyPath string `json:"key_path"` // Path where private key will be written - ChainPath string `json:"chain_path"` // Path where CA chain will be written (optional — if empty, chain appended to cert) - ReloadCommand string `json:"reload_command"` // Command to reload service - ValidateCommand string `json:"validate_command"` // Optional command to validate config before reload + Mode string `json:"mode"` + CertPath string `json:"cert_path"` + KeyPath string `json:"key_path"` + ChainPath string `json:"chain_path"` + ReloadCommand string `json:"reload_command"` + ValidateCommand string `json:"validate_command"` + + // Phase 7: file ownership + mode + verify + retention. + CertFileMode os.FileMode `json:"cert_file_mode,omitempty"` + KeyFileMode os.FileMode `json:"key_file_mode,omitempty"` + ChainFileMode os.FileMode `json:"chain_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 Postfix and Dovecot -// mail servers. This connector runs on the AGENT side and handles local -// certificate deployment for mail server TLS (STARTTLS, SMTPS, IMAPS, POP3S). type Connector struct { config *Config logger *slog.Logger + + runValidate func(ctx context.Context, command string) ([]byte, error) + runReload func(ctx context.Context, command string) ([]byte, error) + probe func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult } -// New creates a new Postfix/Dovecot target connector with the given configuration and logger. func New(config *Config, logger *slog.Logger) *Connector { - return &Connector{ - config: config, - logger: logger, - } + c := &Connector{config: config, logger: logger} + c.runValidate = defaultRunCommand + c.runReload = defaultRunCommand + c.probe = tlsprobe.ProbeTLS + return c +} + +func defaultRunCommand(ctx context.Context, command string) ([]byte, error) { + return exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput() +} + +func (c *Connector) SetTestRunValidate(fn func(ctx context.Context, command string) ([]byte, error)) { + c.runValidate = fn +} +func (c *Connector) SetTestRunReload(fn func(ctx context.Context, command string) ([]byte, error)) { + c.runReload = fn +} +func (c *Connector) SetTestProbe(fn func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult) { + c.probe = fn } -// applyDefaults sets mode-specific default values for any unconfigured fields. func applyDefaults(cfg *Config) { if cfg.Mode == "" { cfg.Mode = "postfix" } - switch cfg.Mode { case "dovecot": if cfg.CertPath == "" { @@ -64,7 +105,7 @@ func applyDefaults(cfg *Config) { if cfg.ValidateCommand == "" { cfg.ValidateCommand = "doveconf -n" } - default: // "postfix" + default: if cfg.CertPath == "" { cfg.CertPath = "/etc/postfix/certs/cert.pem" } @@ -80,24 +121,15 @@ func applyDefaults(cfg *Config) { } } -// ValidateConfig checks that the configuration is valid for the selected mode. -// It applies mode-specific defaults, validates shell commands against injection, -// and verifies the certificate directory exists. 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 mail server config: %w", err) } - - // Validate mode if cfg.Mode != "" && cfg.Mode != "postfix" && cfg.Mode != "dovecot" { return fmt.Errorf("invalid mode %q: must be \"postfix\" or \"dovecot\"", cfg.Mode) } - - // Apply mode-specific defaults applyDefaults(&cfg) - - // Validate commands to prevent injection attacks if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil { return fmt.Errorf("invalid reload_command: %w", err) } @@ -106,205 +138,296 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag return fmt.Errorf("invalid validate_command: %w", err) } } - c.logger.Info("validating mail server configuration", - "mode", cfg.Mode, - "cert_path", cfg.CertPath, - "key_path", cfg.KeyPath, - "chain_path", cfg.ChainPath) - - // Verify certificate directory exists + "mode", cfg.Mode, "cert_path", cfg.CertPath, "key_path", cfg.KeyPath, "chain_path", cfg.ChainPath) certDir := filepath.Dir(cfg.CertPath) if _, err := os.Stat(certDir); os.IsNotExist(err) { return fmt.Errorf("%s cert directory does not exist: %s", cfg.Mode, certDir) } - - // Verify validate command works (best-effort — service might not be installed yet) - if cfg.ValidateCommand != "" { - cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand) - if err := cmd.Run(); err != nil { - c.logger.Warn("config validation command failed during config check", - "error", err, - "mode", cfg.Mode, - "validate_command", cfg.ValidateCommand) - } - } - c.config = &cfg c.logger.Info("mail server configuration validated", "mode", cfg.Mode) return nil } -// DeployCertificate writes the certificate, key, and chain to the configured paths -// and reloads the mail service to pick up the new certificates. -// -// Steps: -// 1. Write certificate to cert_path with mode 0644 (if chain_path empty, append chain) -// 2. Write private key to key_path with mode 0600 -// 3. If chain_path is set, write chain separately with mode 0644 -// 4. Validate configuration (if validate_command is set) -// 5. Reload service +// DeployCertificate atomic + verify + rollback. Mail-specific +// quirk preserved: if ChainPath is empty, the chain is appended to +// the cert (Postfix/Dovecot's "no separate chain" mode). func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) { c.logger.Info("deploying certificate to mail server", - "mode", c.config.Mode, - "cert_path", c.config.CertPath, - "key_path", c.config.KeyPath) - + "mode", c.config.Mode, "cert_path", c.config.CertPath) startTime := time.Now() - // Build certificate data: if chain_path is set, write chain separately; - // otherwise append chain to cert file (fullchain behavior) - certData := request.CertPEM - if request.ChainPEM != "" && c.config.ChainPath == "" { - certData += "\n" + request.ChainPEM - } - - // Write certificate with mode 0644 (rw-r--r--) - if err := os.WriteFile(c.config.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: c.config.CertPath, - Message: errMsg, - DeployedAt: time.Now(), - }, fmt.Errorf("%s", errMsg) - } - - // Write private key with secure permissions (0600: rw-------) - if c.config.KeyPath != "" && request.KeyPEM != "" { - if err := os.WriteFile(c.config.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: c.config.KeyPath, - Message: errMsg, - DeployedAt: time.Now(), - }, fmt.Errorf("%s", errMsg) - } - c.logger.Info("private key written", "key_path", c.config.KeyPath) - } - - // Write chain separately if chain_path is configured - if c.config.ChainPath != "" && request.ChainPEM != "" { - if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil { - errMsg := fmt.Sprintf("failed to write chain: %v", err) - c.logger.Error("chain deployment failed", "error", err) - return &target.DeploymentResult{ - Success: false, - TargetAddress: c.config.ChainPath, - Message: errMsg, - DeployedAt: time.Now(), - }, fmt.Errorf("%s", errMsg) - } - } - - // Validate configuration before reload + plan := c.buildPlan(request) if c.config.ValidateCommand != "" { - c.logger.Debug("validating configuration", "validate_command", c.config.ValidateCommand) - validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand) - if output, err := validateCmd.CombinedOutput(); err != nil { - errMsg := fmt.Sprintf("%s config validation failed: %v (output: %s)", c.config.Mode, err, string(output)) - c.logger.Error("config validation failed", "error", err, "output", string(output)) - return &target.DeploymentResult{ - Success: false, - TargetAddress: c.config.CertPath, - Message: errMsg, - DeployedAt: time.Now(), - }, fmt.Errorf("%s", errMsg) + plan.PreCommit = func(pcCtx context.Context, _ map[string]string) error { + out, err := c.runValidate(pcCtx, c.config.ValidateCommand) + if err != nil { + return fmt.Errorf("%s validate failed: %w (output: %s)", c.config.Mode, err, string(out)) + } + return nil + } + } + plan.PostCommit = func(pcCtx context.Context) error { + out, err := c.runReload(pcCtx, c.config.ReloadCommand) + if err != nil { + return fmt.Errorf("%s reload failed: %w (output: %s)", c.config.Mode, err, string(out)) + } + return nil + } + + res, err := deploy.Apply(ctx, plan) + if err != nil { + return c.failureResult(c.config.CertPath, "deploy.Apply", err, startTime), err + } + + if !res.SkippedAsIdempotent { + if vErr := c.runPostDeployVerify(ctx, request.CertPEM); vErr != nil { + c.logger.Error("post-deploy TLS verify failed; rolling back", "error", vErr) + rbErr := c.rollbackToBackups(ctx, res.BackupPaths) + if rbErr != nil { + return c.failureResult(c.config.CertPath, "verify+rollback both failed", + fmt.Errorf("verify: %w; rollback: %v", vErr, rbErr), startTime), rbErr + } + return c.failureResult(c.config.CertPath, "post-deploy verify failed; rolled back", vErr, startTime), vErr } } - // Reload service - c.logger.Debug("reloading service", "reload_command", c.config.ReloadCommand) - reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand) - if output, err := reloadCmd.CombinedOutput(); err != nil { - errMsg := fmt.Sprintf("%s reload failed: %v (output: %s)", c.config.Mode, err, string(output)) - c.logger.Error("service reload failed", "error", err, "output", string(output)) - return &target.DeploymentResult{ - Success: false, - TargetAddress: c.config.CertPath, - Message: errMsg, - DeployedAt: time.Now(), - }, fmt.Errorf("%s", errMsg) + dur := time.Since(startTime) + idemNote := "" + if res.SkippedAsIdempotent { + idemNote = " (idempotent skip — bytes unchanged)" } - - deploymentDuration := time.Since(startTime) c.logger.Info("certificate deployed to mail server successfully", - "mode", c.config.Mode, - "duration", deploymentDuration.String(), - "cert_path", c.config.CertPath) - + "duration", dur.String(), "mode", c.config.Mode, "idempotent", res.SkippedAsIdempotent) return &target.DeploymentResult{ Success: true, TargetAddress: c.config.CertPath, DeploymentID: fmt.Sprintf("%s-%d", c.config.Mode, time.Now().Unix()), - Message: fmt.Sprintf("Certificate deployed and %s reloaded successfully", c.config.Mode), + Message: fmt.Sprintf("Certificate deployed and %s reloaded successfully%s", c.config.Mode, idemNote), DeployedAt: time.Now(), Metadata: map[string]string{ - "cert_path": c.config.CertPath, - "key_path": c.config.KeyPath, "mode": c.config.Mode, - "duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()), + "cert_path": c.config.CertPath, + "duration_ms": fmt.Sprintf("%d", dur.Milliseconds()), + "idempotent": fmt.Sprintf("%t", res.SkippedAsIdempotent), }, }, nil } -// ValidateDeployment verifies that the deployed certificate is valid and accessible. -// It runs the validate command (if configured) and checks that the cert file exists. +func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error { + if c.config == nil || c.config.ValidateCommand == "" { + return target.ErrValidateOnlyNotSupported + } + out, err := c.runValidate(ctx, c.config.ValidateCommand) + if err != nil { + return fmt.Errorf("%s validate (ValidateOnly): %w (output: %s)", c.config.Mode, err, string(out)) + } + return nil +} + +func (c *Connector) buildPlan(request target.DeploymentRequest) deploy.Plan { + // Postfix/Dovecot quirk: if ChainPath is empty, append chain + // to cert for serving as a single-file bundle. + certBytes := []byte(request.CertPEM) + if c.config.ChainPath == "" && request.ChainPEM != "" { + certBytes = append(certBytes, []byte("\n"+request.ChainPEM)...) + } + files := []deploy.File{{ + Path: c.config.CertPath, + Bytes: certBytes, + Mode: c.config.CertFileMode, + Owner: c.config.CertFileOwner, + Group: c.config.CertFileGroup, + }} + if c.config.ChainPath != "" && request.ChainPEM != "" { + files = append(files, deploy.File{ + Path: c.config.ChainPath, + Bytes: []byte(request.ChainPEM), + Mode: c.config.ChainFileMode, + }) + } + if c.config.KeyPath != "" && request.KeyPEM != "" { + mode := c.config.KeyFileMode + if mode == 0 { + mode = 0600 // back-compat: Postfix keys 0600 + } + files = append(files, deploy.File{ + Path: c.config.KeyPath, + Bytes: []byte(request.KeyPEM), + Mode: mode, + Owner: c.config.KeyFileOwner, + Group: c.config.KeyFileGroup, + }) + } + defaultUser := pickFirstExistingUser("postfix", "dovecot", "_postfix") + defaultGroup := pickFirstExistingGroup("postfix", "dovecot", "_postfix") + return deploy.Plan{ + Files: files, + Defaults: deploy.FileDefaults{Mode: 0644, Owner: defaultUser, Group: defaultGroup}, + BackupRetention: c.config.BackupRetention, + } +} + +func (c *Connector) runPostDeployVerify(ctx context.Context, deployedCertPEM string) error { + verify := c.config.PostDeployVerify + if verify != nil && !verify.Enabled { + return nil + } + endpoint := "" + timeout := 10 * time.Second + if verify != nil { + endpoint = verify.Endpoint + if verify.Timeout > 0 { + timeout = verify.Timeout + } + } + if endpoint == "" { + c.logger.Warn("post-deploy verify enabled but no endpoint configured; skipping") + return nil + } + want, err := certPEMToFingerprint(deployedCertPEM) + if err != nil { + return fmt.Errorf("compute deployed cert 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, endpoint, timeout) + if !res.Success { + lastErr = fmt.Errorf("TLS probe failed: %s", res.Error) + continue + } + got := strings.ToLower(res.Fingerprint) + want = strings.ToLower(want) + if got == want { + return nil + } + lastErr = fmt.Errorf("post-deploy TLS verify SHA-256 mismatch: got %s, want %s", got, want) + } + return lastErr +} + +func (c *Connector) rollbackToBackups(ctx context.Context, backupPaths map[string]string) error { + for finalPath, backupPath := range backupPaths { + if backupPath == "" { + if err := os.Remove(finalPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("rollback remove %s: %w", finalPath, err) + } + continue + } + bytes, err := os.ReadFile(backupPath) + if err != nil { + return fmt.Errorf("rollback read backup %s: %w", backupPath, err) + } + if _, err := deploy.AtomicWriteFile(ctx, finalPath, bytes, deploy.WriteOptions{ + SkipIdempotent: true, + BackupRetention: -1, + }); err != nil { + return fmt.Errorf("rollback write %s: %w", finalPath, err) + } + } + out, err := c.runReload(ctx, c.config.ReloadCommand) + if err != nil { + return fmt.Errorf("rollback reload failed: %w (output: %s)", err, string(out)) + } + 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 block 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 decode: %w", err) + } + h := sha256.Sum256(der) + return hex.EncodeToString(h[:]), nil +} + +func pickFirstExistingUser(candidates ...string) string { + for _, name := range candidates { + if _, err := user.Lookup(name); err == nil { + return name + } + } + return "" +} +func pickFirstExistingGroup(candidates ...string) string { + for _, name := range candidates { + if _, err := user.LookupGroup(name); err == nil { + return name + } + } + return "" +} + func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) { c.logger.Info("validating mail server deployment", - "mode", c.config.Mode, - "certificate_id", request.CertificateID, - "serial", request.Serial) - + "mode", c.config.Mode, "certificate_id", request.CertificateID, "serial", request.Serial) startTime := time.Now() - - // Validate configuration if validate command is set if c.config.ValidateCommand != "" { - validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand) - if output, err := validateCmd.CombinedOutput(); err != nil { - errMsg := fmt.Sprintf("%s config validation failed: %v (output: %s)", c.config.Mode, err, string(output)) - c.logger.Error("validation failed", "error", err) + if _, err := c.runValidate(ctx, c.config.ValidateCommand); err != nil { + errMsg := fmt.Sprintf("%s config validation failed: %v", c.config.Mode, err) return &target.ValidationResult{ - Valid: false, - Serial: request.Serial, - TargetAddress: c.config.CertPath, - Message: errMsg, - ValidatedAt: time.Now(), + Valid: false, Serial: request.Serial, TargetAddress: c.config.CertPath, + Message: errMsg, ValidatedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } } - - // Verify certificate file exists and is readable if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) { errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath) - c.logger.Error("validation failed", "error", err) return &target.ValidationResult{ - Valid: false, - Serial: request.Serial, - TargetAddress: c.config.CertPath, - Message: errMsg, - ValidatedAt: time.Now(), + Valid: false, Serial: request.Serial, TargetAddress: c.config.CertPath, + Message: errMsg, ValidatedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } - - validationDuration := time.Since(startTime) - c.logger.Info("mail server deployment validated successfully", - "mode", c.config.Mode, - "duration", validationDuration.String()) - + dur := time.Since(startTime) return &target.ValidationResult{ - Valid: true, - Serial: request.Serial, - TargetAddress: c.config.CertPath, - Message: fmt.Sprintf("%s configuration valid and certificate accessible", c.config.Mode), - ValidatedAt: time.Now(), + Valid: true, Serial: request.Serial, TargetAddress: c.config.CertPath, + Message: fmt.Sprintf("%s configuration valid", c.config.Mode), ValidatedAt: time.Now(), Metadata: map[string]string{ - "mode": c.config.Mode, - "validate_command": c.config.ValidateCommand, - "duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()), + "mode": c.config.Mode, "validate_command": c.config.ValidateCommand, + "duration_ms": fmt.Sprintf("%d", dur.Milliseconds()), }, }, nil } diff --git a/internal/connector/target/postfix/postfix_atomic_test.go b/internal/connector/target/postfix/postfix_atomic_test.go new file mode 100644 index 0000000..2280eca --- /dev/null +++ b/internal/connector/target/postfix/postfix_atomic_test.go @@ -0,0 +1,246 @@ +package postfix_test + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/connector/target/postfix" + "github.com/shankar0123/certctl/internal/deploy" + "github.com/shankar0123/certctl/internal/tlsprobe" +) + +// Phase 7 of the deploy-hardening I master bundle: atomic + verify +// + rollback for Postfix/Dovecot. Pre-existing 18 tests + these +// new ones puts the connector well above the >=20 target. + +const ( + certA = "-----BEGIN CERTIFICATE-----\nQUxQSEEtQ0VSVA==\n-----END CERTIFICATE-----\n" + chain = "-----BEGIN CERTIFICATE-----\nSU5UQ0hBSU4=\n-----END CERTIFICATE-----\n" + 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, cfg *postfix.Config) *postfix.Connector { + c := postfix.New(cfg, quietLogger()) + c.SetTestRunValidate(func(_ context.Context, _ string) ([]byte, error) { return nil, nil }) + c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) { return nil, nil }) + c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult { + return tlsprobe.ProbeResult{Success: true, Fingerprint: "x"} + }) + return c +} + +func cfg(dir string) *postfix.Config { + return &postfix.Config{ + Mode: "postfix", + CertPath: filepath.Join(dir, "cert.pem"), + KeyPath: filepath.Join(dir, "key.pem"), + ChainPath: filepath.Join(dir, "chain.pem"), + ReloadCommand: "postfix reload", + ValidateCommand: "postfix check", + } +} + +func TestPostfix_HappyPath(t *testing.T) { + c := newC(t, cfg(t.TempDir())) + res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain, KeyPEM: keyA}) + if err != nil || !res.Success { + t.Fatal(err) + } +} + +func TestPostfix_ValidateFails(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + os.WriteFile(cert, []byte("OLD"), 0644) + c := newC(t, &postfix.Config{Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x"}) + c.SetTestRunValidate(func(_ context.Context, _ string) ([]byte, error) { + return []byte("err"), errors.New("bad config") + }) + _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}) + if !errors.Is(err, deploy.ErrValidateFailed) { + t.Errorf("got %v", err) + } + if got, _ := os.ReadFile(cert); string(got) != "OLD" { + t.Error("cert modified") + } +} + +func TestPostfix_ReloadFails_Rollback(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + os.WriteFile(cert, []byte("OLD"), 0644) + c := newC(t, &postfix.Config{Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x"}) + var n int32 + c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) { + if atomic.AddInt32(&n, 1) == 1 { + return nil, errors.New("reload failed") + } + return nil, nil + }) + _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}) + if !errors.Is(err, deploy.ErrReloadFailed) { + t.Errorf("got %v", err) + } +} + +func TestPostfix_VerifyMismatch_Rollback(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + os.WriteFile(cert, []byte("ORIG"), 0644) + cfgV := &postfix.Config{ + Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x", + PostDeployVerifyAttempts: 1, + PostDeployVerify: &postfix.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:25"}, + } + c := newC(t, cfgV) + 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.Error("expected verify error") + } +} + +func TestPostfix_VerifyMatch_Success(t *testing.T) { + dir := t.TempDir() + cfgV := &postfix.Config{ + Mode: "postfix", CertPath: filepath.Join(dir, "cert.pem"), ReloadCommand: "x", ValidateCommand: "x", + PostDeployVerifyAttempts: 1, + PostDeployVerify: &postfix.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:25"}, + } + c := newC(t, cfgV) + 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 TestPostfix_Idempotency(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + os.WriteFile(cert, []byte(certA), 0644) + c := newC(t, &postfix.Config{Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x"}) + var n int32 + c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) { + atomic.AddInt32(&n, 1) + return nil, nil + }) + c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}) + if n != 0 { + t.Errorf("reload calls = %d", n) + } +} + +func TestPostfix_ChainAppendedToCert_WhenNoChainPath(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + c := newC(t, &postfix.Config{Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x"}) + c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain}) + body, _ := os.ReadFile(cert) + s := string(body) + if !strings.Contains(s, "ALPHA") || !strings.Contains(s, "INTCHAIN") { + // (b64 encoded — check headers instead) + } + first := strings.Index(s, "BEGIN CERTIFICATE") + second := strings.Index(s[first+1:], "BEGIN CERTIFICATE") + if second < 0 { + t.Errorf("chain not appended to cert: %s", s) + } +} + +func TestPostfix_DefaultKeyMode_0600(t *testing.T) { + dir := t.TempDir() + c := newC(t, &postfix.Config{ + Mode: "postfix", CertPath: filepath.Join(dir, "cert.pem"), + KeyPath: filepath.Join(dir, "key.pem"), + ReloadCommand: "x", ValidateCommand: "x", + }) + 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 TestPostfix_ValidateOnly_Happy(t *testing.T) { + c := newC(t, cfg(t.TempDir())) + if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil { + t.Errorf("got %v", err) + } +} + +func TestPostfix_ValidateOnly_Sentinel_NoCommand(t *testing.T) { + c := postfix.New(&postfix.Config{}, quietLogger()) + if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) { + t.Errorf("got %v", err) + } +} + +func TestPostfix_BackupRetention(t *testing.T) { + dir := t.TempDir() + cert := filepath.Join(dir, "cert.pem") + os.WriteFile(cert, []byte("V0"), 0644) + c := newC(t, &postfix.Config{ + Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x", BackupRetention: 2, + }) + for i := 1; i <= 4; i++ { + c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: fmt.Sprintf("V%d-CERT", i)}) + time.Sleep(2 * time.Millisecond) + } + entries, _ := os.ReadDir(dir) + cnt := 0 + for _, e := range entries { + if strings.Contains(e.Name(), deploy.BackupSuffix) { + cnt++ + } + } + if cnt != 2 { + t.Errorf("count = %d", cnt) + } +} + +func TestPostfix_DovecotMode(t *testing.T) { + dir := t.TempDir() + c := newC(t, &postfix.Config{ + Mode: "dovecot", CertPath: filepath.Join(dir, "cert.pem"), + ReloadCommand: "doveadm reload", ValidateCommand: "doveconf -n", + }) + res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}) + if err != nil || !res.Success { + t.Fatal(err) + } + if !strings.HasPrefix(res.DeploymentID, "dovecot-") { + t.Errorf("DeploymentID = %q", res.DeploymentID) + } +} diff --git a/internal/connector/target/postfix/validate_only.go b/internal/connector/target/postfix/validate_only.go deleted file mode 100644 index c55125f..0000000 --- a/internal/connector/target/postfix/validate_only.go +++ /dev/null @@ -1,18 +0,0 @@ -package postfix - -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 postfix 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 -} diff --git a/internal/connector/target/traefik/traefik.go b/internal/connector/target/traefik/traefik.go index 9113e18..8c721d7 100644 --- a/internal/connector/target/traefik/traefik.go +++ b/internal/connector/target/traefik/traefik.go @@ -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 } diff --git a/internal/connector/target/traefik/traefik_atomic_test.go b/internal/connector/target/traefik/traefik_atomic_test.go new file mode 100644 index 0000000..b70156d --- /dev/null +++ b/internal/connector/target/traefik/traefik_atomic_test.go @@ -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) + } +} diff --git a/internal/connector/target/traefik/validate_only.go b/internal/connector/target/traefik/validate_only.go deleted file mode 100644 index b9059a8..0000000 --- a/internal/connector/target/traefik/validate_only.go +++ /dev/null @@ -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 -}