mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
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:
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user