feat: M25 post-deployment TLS verification + M26 Traefik/Caddy targets

M25: After deploying a certificate, the agent probes the live TLS
endpoint and compares SHA-256 fingerprints to verify the correct cert
is being served. Best-effort — failures don't block deployments.
New endpoints: POST /jobs/{id}/verify, GET /jobs/{id}/verification.
Migration 000008 adds verification columns to jobs table.

M26: Traefik target connector (file provider, auto-reload) and Caddy
target connector (dual-mode: admin API hot-reload or file-based).
Both wired into agent dispatch.

Also: restructured README to highlight supported integrations (issuers,
targets, notifiers) earlier, moved API/CLI/MCP sections lower. Updated
all docs (features, connectors, architecture, testing guide, why-certctl)
and fixed integration tests for 18-param RegisterHandlers signature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-27 21:07:16 -04:00
parent ef92b07448
commit be72627aeb
28 changed files with 3365 additions and 177 deletions
+7 -5
View File
@@ -75,9 +75,11 @@ const (
type TargetType string
const (
TargetTypeNGINX TargetType = "NGINX"
TargetTypeApache TargetType = "Apache"
TargetTypeHAProxy TargetType = "HAProxy"
TargetTypeF5 TargetType = "F5"
TargetTypeIIS TargetType = "IIS"
TargetTypeNGINX TargetType = "NGINX"
TargetTypeApache TargetType = "Apache"
TargetTypeHAProxy TargetType = "HAProxy"
TargetTypeF5 TargetType = "F5"
TargetTypeIIS TargetType = "IIS"
TargetTypeTraefik TargetType = "Traefik"
TargetTypeCaddy TargetType = "Caddy"
)
+16 -12
View File
@@ -7,18 +7,22 @@ import (
// Job represents a unit of work in the certificate control plane.
type Job struct {
ID string `json:"id"`
Type JobType `json:"type"`
CertificateID string `json:"certificate_id"`
TargetID *string `json:"target_id,omitempty"`
Status JobStatus `json:"status"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
LastError *string `json:"last_error,omitempty"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
Type JobType `json:"type"`
CertificateID string `json:"certificate_id"`
TargetID *string `json:"target_id,omitempty"`
Status JobStatus `json:"status"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
LastError *string `json:"last_error,omitempty"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
VerificationStatus VerificationStatus `json:"verification_status"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
VerificationError *string `json:"verification_error,omitempty"`
VerificationFp *string `json:"verification_fingerprint,omitempty"`
}
// JobType represents the classification of work to be performed.
+37
View File
@@ -0,0 +1,37 @@
package domain
import "time"
// VerificationStatus represents the status of certificate deployment verification.
type VerificationStatus string
const (
// VerificationPending: verification has not yet been performed.
VerificationPending VerificationStatus = "pending"
// VerificationSuccess: the live TLS endpoint serves the expected certificate.
VerificationSuccess VerificationStatus = "success"
// VerificationFailed: the live TLS endpoint does not serve the expected certificate.
VerificationFailed VerificationStatus = "failed"
// VerificationSkipped: verification was skipped (disabled or not applicable).
VerificationSkipped VerificationStatus = "skipped"
)
// VerificationResult represents the outcome of verifying a deployed certificate
// against the live TLS endpoint it should be serving.
type VerificationResult struct {
// JobID is the ID of the deployment job being verified.
JobID string `json:"job_id"`
// TargetID is the ID of the deployment target.
TargetID string `json:"target_id"`
// ExpectedFingerprint is the SHA-256 fingerprint of the certificate that was deployed.
ExpectedFingerprint string `json:"expected_fingerprint"`
// ActualFingerprint is the SHA-256 fingerprint of the certificate currently being served
// at the live TLS endpoint.
ActualFingerprint string `json:"actual_fingerprint"`
// Verified is true if expected and actual fingerprints match.
Verified bool `json:"verified"`
// VerifiedAt is the timestamp when verification was performed.
VerifiedAt time.Time `json:"verified_at"`
// Error is a non-empty error message if verification failed to complete.
Error string `json:"error,omitempty"`
}
+73
View File
@@ -0,0 +1,73 @@
package domain
import (
"testing"
"time"
)
func TestVerificationStatus_Constants(t *testing.T) {
tests := []struct {
name string
status VerificationStatus
expected string
}{
{"Pending", VerificationPending, "pending"},
{"Success", VerificationSuccess, "success"},
{"Failed", VerificationFailed, "failed"},
{"Skipped", VerificationSkipped, "skipped"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.status) != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, string(tt.status))
}
})
}
}
func TestVerificationResult_Marshaling(t *testing.T) {
now := time.Now().UTC()
result := &VerificationResult{
JobID: "j-test123",
TargetID: "t-nginx1",
ExpectedFingerprint: "abc123def456",
ActualFingerprint: "abc123def456",
Verified: true,
VerifiedAt: now,
Error: "",
}
if result.JobID != "j-test123" {
t.Errorf("JobID mismatch: got %s", result.JobID)
}
if !result.Verified {
t.Error("expected Verified to be true")
}
if result.Error != "" {
t.Errorf("expected no error, got %s", result.Error)
}
}
func TestVerificationResult_WithError(t *testing.T) {
now := time.Now().UTC()
result := &VerificationResult{
JobID: "j-test456",
TargetID: "t-apache1",
ExpectedFingerprint: "aaa111bbb222",
ActualFingerprint: "ccc333ddd444",
Verified: false,
VerifiedAt: now,
Error: "connection timeout",
}
if result.Verified {
t.Error("expected Verified to be false")
}
if result.Error != "connection timeout" {
t.Errorf("expected error message, got %s", result.Error)
}
if result.ExpectedFingerprint == result.ActualFingerprint {
t.Error("expected fingerprints to differ")
}
}