mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 08:09:15 +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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user