mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 08:38:53 +00:00
feat(m28+m29+m30): ACME ARI, email digest, and Helm chart
M28: ACME Renewal Information (RFC 9702) — CA-directed renewal timing with cert ID computation, directory endpoint discovery, graceful degradation for non-ARI CAs. 19 tests. M29: Email notifier wiring + scheduled certificate digest — SMTP connector bridged to service layer via NotifierAdapter, DigestService with HTML email template, 7th scheduler loop (24h), digest preview/send API endpoints and GUI card. 21 tests. M30: Production-ready Helm chart — server Deployment, PostgreSQL StatefulSet, agent DaemonSet, ConfigMaps, Secrets, Ingress, security contexts, health probes, example values for dev/prod/ACME scenarios. Also: OpenAPI spec updates, MCP tool additions, CI helm-lint job, documentation updates across 5 doc files and README. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// DigestServicer defines the interface for digest operations used by the handler.
|
||||
type DigestServicer interface {
|
||||
PreviewDigest(ctx context.Context) (string, error)
|
||||
SendDigest(ctx context.Context) error
|
||||
}
|
||||
|
||||
// DigestHandler provides HTTP endpoints for certificate digest operations.
|
||||
type DigestHandler struct {
|
||||
service DigestServicer
|
||||
}
|
||||
|
||||
// NewDigestHandler creates a new digest handler.
|
||||
func NewDigestHandler(service DigestServicer) *DigestHandler {
|
||||
return &DigestHandler{service: service}
|
||||
}
|
||||
|
||||
// PreviewDigest renders the digest HTML without sending it.
|
||||
// GET /api/v1/digest/preview
|
||||
func (h *DigestHandler) PreviewDigest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if h.service == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "digest service not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
html, err := h.service.PreviewDigest(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// SendDigest triggers an immediate digest send.
|
||||
// POST /api/v1/digest/send
|
||||
func (h *DigestHandler) SendDigest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if h.service == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "digest service not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.SendDigest(r.Context()); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "sent"})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockDigestService implements DigestServicer for testing.
|
||||
type mockDigestService struct {
|
||||
previewHTML string
|
||||
previewErr error
|
||||
sendErr error
|
||||
sendCalled bool
|
||||
}
|
||||
|
||||
func (m *mockDigestService) PreviewDigest(ctx context.Context) (string, error) {
|
||||
if m.previewErr != nil {
|
||||
return "", m.previewErr
|
||||
}
|
||||
return m.previewHTML, nil
|
||||
}
|
||||
|
||||
func (m *mockDigestService) SendDigest(ctx context.Context) error {
|
||||
m.sendCalled = true
|
||||
return m.sendErr
|
||||
}
|
||||
|
||||
func TestDigestHandler_PreviewDigest_Success(t *testing.T) {
|
||||
svc := &mockDigestService{
|
||||
previewHTML: "<html><body>Digest Preview</body></html>",
|
||||
}
|
||||
h := NewDigestHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/preview", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.PreviewDigest(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if w.Header().Get("Content-Type") != "text/html; charset=utf-8" {
|
||||
t.Errorf("expected Content-Type text/html, got %s", w.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
if w.Body.String() != "<html><body>Digest Preview</body></html>" {
|
||||
t.Errorf("unexpected body: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestHandler_PreviewDigest_MethodNotAllowed(t *testing.T) {
|
||||
svc := &mockDigestService{}
|
||||
h := NewDigestHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/preview", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.PreviewDigest(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestHandler_PreviewDigest_ServiceError(t *testing.T) {
|
||||
svc := &mockDigestService{
|
||||
previewErr: errors.New("stats unavailable"),
|
||||
}
|
||||
h := NewDigestHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/preview", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.PreviewDigest(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestHandler_PreviewDigest_NotConfigured(t *testing.T) {
|
||||
h := NewDigestHandler(nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/preview", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.PreviewDigest(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected status 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestHandler_SendDigest_Success(t *testing.T) {
|
||||
svc := &mockDigestService{}
|
||||
h := NewDigestHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/send", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.SendDigest(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if !svc.sendCalled {
|
||||
t.Error("expected SendDigest to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestHandler_SendDigest_MethodNotAllowed(t *testing.T) {
|
||||
svc := &mockDigestService{}
|
||||
h := NewDigestHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/send", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.SendDigest(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestHandler_SendDigest_ServiceError(t *testing.T) {
|
||||
svc := &mockDigestService{
|
||||
sendErr: errors.New("SMTP connection refused"),
|
||||
}
|
||||
h := NewDigestHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/send", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.SendDigest(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestHandler_SendDigest_NotConfigured(t *testing.T) {
|
||||
h := NewDigestHandler(nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/send", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.SendDigest(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected status 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ type HandlerRegistry struct {
|
||||
NetworkScan handler.NetworkScanHandler
|
||||
Verification handler.VerificationHandler
|
||||
Export handler.ExportHandler
|
||||
Digest handler.DigestHandler
|
||||
}
|
||||
|
||||
// RegisterHandlers sets up all API routes with their handlers.
|
||||
@@ -220,6 +221,10 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
|
||||
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment))
|
||||
r.Register("GET /api/v1/jobs/{id}/verification", http.HandlerFunc(reg.Verification.GetVerificationStatus))
|
||||
|
||||
// Digest routes: /api/v1/digest
|
||||
r.Register("GET /api/v1/digest/preview", http.HandlerFunc(reg.Digest.PreviewDigest))
|
||||
r.Register("POST /api/v1/digest/send", http.HandlerFunc(reg.Digest.SendDigest))
|
||||
}
|
||||
|
||||
// RegisterESTHandlers sets up EST (RFC 7030) routes under /.well-known/est/.
|
||||
|
||||
@@ -24,6 +24,8 @@ type Config struct {
|
||||
NetworkScan NetworkScanConfig
|
||||
EST ESTConfig
|
||||
Verification VerificationConfig
|
||||
ACME ACMEConfig
|
||||
Digest DigestConfig
|
||||
}
|
||||
|
||||
// NotifierConfig contains configuration for notification connectors.
|
||||
@@ -64,6 +66,34 @@ type NotifierConfig struct {
|
||||
// OpsGeniePriority sets the default priority for OpsGenie alerts.
|
||||
// Valid values: "P1", "P2", "P3", "P4", "P5". Default: "P3".
|
||||
OpsGeniePriority string
|
||||
|
||||
// SMTPHost is the SMTP server hostname for sending email notifications.
|
||||
// Example: "smtp.gmail.com", "smtp.sendgrid.net". Required for email notifications.
|
||||
// Setting: CERTCTL_SMTP_HOST environment variable.
|
||||
SMTPHost string
|
||||
|
||||
// SMTPPort is the SMTP server port. Default: 587 (STARTTLS).
|
||||
// Common values: 25 (plain), 465 (implicit TLS), 587 (STARTTLS).
|
||||
// Setting: CERTCTL_SMTP_PORT environment variable.
|
||||
SMTPPort int
|
||||
|
||||
// SMTPUsername is the SMTP authentication username.
|
||||
// Setting: CERTCTL_SMTP_USERNAME environment variable.
|
||||
SMTPUsername string
|
||||
|
||||
// SMTPPassword is the SMTP authentication password or app-specific password.
|
||||
// Setting: CERTCTL_SMTP_PASSWORD environment variable.
|
||||
SMTPPassword string
|
||||
|
||||
// SMTPFromAddress is the sender email address for outbound notifications.
|
||||
// Example: "certctl@example.com", "noreply@company.com".
|
||||
// Setting: CERTCTL_SMTP_FROM_ADDRESS environment variable.
|
||||
SMTPFromAddress string
|
||||
|
||||
// SMTPUseTLS enables TLS for the SMTP connection.
|
||||
// Default: true. Set to false for plain SMTP (not recommended).
|
||||
// Setting: CERTCTL_SMTP_USE_TLS environment variable.
|
||||
SMTPUseTLS bool
|
||||
}
|
||||
|
||||
// KeygenConfig controls where private keys are generated.
|
||||
@@ -111,6 +141,24 @@ type StepCAConfig struct {
|
||||
ProvisionerPassword string
|
||||
}
|
||||
|
||||
// DigestConfig controls the scheduled certificate digest email feature.
|
||||
type DigestConfig struct {
|
||||
// Enabled controls whether periodic digest emails are generated and sent.
|
||||
// Default: false. When enabled, requires SMTP to be configured.
|
||||
// Setting: CERTCTL_DIGEST_ENABLED environment variable.
|
||||
Enabled bool
|
||||
|
||||
// Interval is how often digest emails are generated and sent.
|
||||
// Default: 24 hours. Minimum: 1 hour.
|
||||
// Setting: CERTCTL_DIGEST_INTERVAL environment variable.
|
||||
Interval time.Duration
|
||||
|
||||
// Recipients is a comma-separated list of email addresses to receive digest emails.
|
||||
// If empty, digests are sent to all certificate owners.
|
||||
// Setting: CERTCTL_DIGEST_RECIPIENTS environment variable.
|
||||
Recipients []string
|
||||
}
|
||||
|
||||
// ACMEConfig contains ACME issuer connector configuration.
|
||||
type ACMEConfig struct {
|
||||
// DirectoryURL is the ACME directory URL for certificate issuance.
|
||||
@@ -144,6 +192,13 @@ type ACMEConfig struct {
|
||||
// Example: "letsencrypt.org" or "zerossl.com". Only used if ChallengeType is "dns-persist-01".
|
||||
// The record value becomes: "<issuer_domain>; accounturi=<acme_account_uri>"
|
||||
DNSPersistIssuerDomain string
|
||||
|
||||
// ARIEnabled enables ACME Renewal Information (RFC 9702) support.
|
||||
// When enabled, the renewal scheduler queries the CA for suggested renewal windows
|
||||
// instead of relying solely on static expiration thresholds.
|
||||
// Default: false. Requires a CA that supports ARI (e.g., Let's Encrypt).
|
||||
// Setting: CERTCTL_ACME_ARI_ENABLED environment variable.
|
||||
ARIEnabled bool
|
||||
}
|
||||
|
||||
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
|
||||
@@ -349,6 +404,12 @@ func Load() (*Config, error) {
|
||||
PagerDutySeverity: getEnv("CERTCTL_PAGERDUTY_SEVERITY", "warning"),
|
||||
OpsGenieAPIKey: getEnv("CERTCTL_OPSGENIE_API_KEY", ""),
|
||||
OpsGeniePriority: getEnv("CERTCTL_OPSGENIE_PRIORITY", "P3"),
|
||||
SMTPHost: getEnv("CERTCTL_SMTP_HOST", ""),
|
||||
SMTPPort: getEnvInt("CERTCTL_SMTP_PORT", 587),
|
||||
SMTPUsername: getEnv("CERTCTL_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getEnv("CERTCTL_SMTP_PASSWORD", ""),
|
||||
SMTPFromAddress: getEnv("CERTCTL_SMTP_FROM_ADDRESS", ""),
|
||||
SMTPUseTLS: getEnvBool("CERTCTL_SMTP_USE_TLS", true),
|
||||
},
|
||||
NetworkScan: NetworkScanConfig{
|
||||
Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false),
|
||||
@@ -364,6 +425,20 @@ func Load() (*Config, error) {
|
||||
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
|
||||
Delay: getEnvDuration("CERTCTL_VERIFY_DELAY", 2*time.Second),
|
||||
},
|
||||
ACME: ACMEConfig{
|
||||
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
|
||||
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
|
||||
ChallengeType: getEnv("CERTCTL_ACME_CHALLENGE_TYPE", "http-01"),
|
||||
DNSPresentScript: getEnv("CERTCTL_ACME_DNS_PRESENT_SCRIPT", ""),
|
||||
DNSCleanUpScript: getEnv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT", ""),
|
||||
DNSPersistIssuerDomain: getEnv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN", ""),
|
||||
ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
|
||||
},
|
||||
Digest: DigestConfig{
|
||||
Enabled: getEnvBool("CERTCTL_DIGEST_ENABLED", false),
|
||||
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),
|
||||
Recipients: getEnvList("CERTCTL_DIGEST_RECIPIENTS", nil),
|
||||
},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
|
||||
@@ -54,6 +54,10 @@ type Config struct {
|
||||
// Used to construct the TXT record value: "<issuer-domain>; accounturi=<account-uri>".
|
||||
// Required when ChallengeType is "dns-persist-01". For Let's Encrypt, use "letsencrypt.org".
|
||||
DNSPersistIssuerDomain string `json:"dns_persist_issuer_domain,omitempty"`
|
||||
|
||||
// ARIEnabled enables ACME Renewal Information (RFC 9702) support per CERTCTL_ACME_ARI_ENABLED.
|
||||
// When enabled, the connector queries the CA's ARI endpoint to get CA-directed renewal timing.
|
||||
ARIEnabled bool `json:"ari_enabled,omitempty"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for ACME-compatible CAs
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9702 for a certificate.
|
||||
// certPEM is the PEM-encoded certificate. Returns nil, nil if the CA does not support ARI.
|
||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
if !c.config.ARIEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := c.ensureClient(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ACME client init: %w", err)
|
||||
}
|
||||
|
||||
// Parse the certificate to compute the ARI certificate ID
|
||||
certID, err := computeARICertID(certPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compute ARI cert ID: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Debug("retrieving ARI for certificate",
|
||||
"cert_id", certID)
|
||||
|
||||
// Fetch the ACME directory to find the renewalInfo endpoint
|
||||
renewalInfoURL, err := c.getARIEndpoint(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to construct ARI endpoint: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Debug("querying ARI endpoint", "url", renewalInfoURL)
|
||||
|
||||
// Make GET request to the ARI endpoint
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, renewalInfoURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ARI request: %w", err)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ARI request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ARI response: %w", err)
|
||||
}
|
||||
|
||||
// 404 means the CA doesn't support ARI or the cert doesn't exist
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
c.logger.Debug("ARI not supported by CA or cert not found")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Other non-2xx errors
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("ARI endpoint returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse the ARI response
|
||||
var ariResp struct {
|
||||
SuggestedWindow struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
} `json:"suggestedWindow"`
|
||||
RetryAfter time.Time `json:"retryAfter,omitempty"`
|
||||
ExplanationURL string `json:"explanationURL,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &ariResp); err != nil {
|
||||
return nil, fmt.Errorf("parse ARI response: %w", err)
|
||||
}
|
||||
|
||||
if ariResp.SuggestedWindow.Start.IsZero() || ariResp.SuggestedWindow.End.IsZero() {
|
||||
return nil, fmt.Errorf("invalid ARI response: missing or empty suggestedWindow")
|
||||
}
|
||||
|
||||
c.logger.Info("retrieved ARI",
|
||||
"window_start", ariResp.SuggestedWindow.Start,
|
||||
"window_end", ariResp.SuggestedWindow.End)
|
||||
|
||||
return &issuer.RenewalInfoResult{
|
||||
SuggestedWindowStart: ariResp.SuggestedWindow.Start,
|
||||
SuggestedWindowEnd: ariResp.SuggestedWindow.End,
|
||||
RetryAfter: ariResp.RetryAfter,
|
||||
ExplanationURL: ariResp.ExplanationURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// computeARICertID computes the ARI certificate ID as defined in RFC 9702.
|
||||
// The cert ID is base64url(SHA256(DER encoding of the certificate)).
|
||||
func computeARICertID(certPEM string) (string, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid PEM: no certificate block found")
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(block.Bytes)
|
||||
certID := base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
return certID, nil
|
||||
}
|
||||
|
||||
// getARIEndpoint constructs the ARI endpoint URL from the ACME directory.
|
||||
// It fetches the directory JSON and extracts the "renewalInfo" field if available.
|
||||
// Falls back to a standard URL pattern if the directory doesn't advertise renewalInfo.
|
||||
func (c *Connector) getARIEndpoint(ctx context.Context, certID string) (string, error) {
|
||||
// Try to fetch and parse the directory
|
||||
httpClient := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.DirectoryURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create directory request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
// If we can't fetch the directory, try the standard Let's Encrypt pattern
|
||||
return constructARIURLFallback(c.config.DirectoryURL, certID), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return constructARIURLFallback(c.config.DirectoryURL, certID), nil
|
||||
}
|
||||
|
||||
var dir struct {
|
||||
RenewalInfo string `json:"renewalInfo,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &dir); err != nil {
|
||||
// Malformed directory; use fallback
|
||||
return constructARIURLFallback(c.config.DirectoryURL, certID), nil
|
||||
}
|
||||
|
||||
if dir.RenewalInfo != "" {
|
||||
// Directory advertises renewalInfo endpoint
|
||||
return dir.RenewalInfo + "/" + certID, nil
|
||||
}
|
||||
|
||||
// No renewalInfo in directory; use standard fallback
|
||||
return constructARIURLFallback(c.config.DirectoryURL, certID), nil
|
||||
}
|
||||
|
||||
// constructARIURLFallback builds an ARI endpoint URL using a standard pattern.
|
||||
// It replaces "/directory" with "/renewalInfo" in the URL.
|
||||
func constructARIURLFallback(directoryURL, certID string) string {
|
||||
// Replace "/directory" with "/renewalInfo/{certID}"
|
||||
// For Let's Encrypt: https://acme-v02.api.letsencrypt.org/directory
|
||||
// becomes: https://acme-v02.api.letsencrypt.org/renewalInfo/{certID}
|
||||
baseURL := strings.TrimSuffix(directoryURL, "/directory")
|
||||
return baseURL + "/renewalInfo/" + certID
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestComputeARICertID_InvalidPEM_Input tests the ARI certificate ID computation with invalid PEM.
|
||||
func TestComputeARICertID_InvalidPEM_Input(t *testing.T) {
|
||||
// Test with invalid PEM data
|
||||
_, err := computeARICertID("not a valid pem")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructARIURLFallback_LetsEncrypt(t *testing.T) {
|
||||
directoryURL := "https://acme-v02.api.letsencrypt.org/directory"
|
||||
certID := "abc123"
|
||||
|
||||
url := constructARIURLFallback(directoryURL, certID)
|
||||
|
||||
expected := "https://acme-v02.api.letsencrypt.org/renewalInfo/abc123"
|
||||
if url != expected {
|
||||
t.Errorf("constructARIURLFallback: expected %s, got %s", expected, url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructARIURLFallback_NoDirectory(t *testing.T) {
|
||||
directoryURL := "https://example.com/acme"
|
||||
certID := "xyz789"
|
||||
|
||||
url := constructARIURLFallback(directoryURL, certID)
|
||||
|
||||
expected := "https://example.com/acme/renewalInfo/xyz789"
|
||||
if url != expected {
|
||||
t.Errorf("constructARIURLFallback: expected %s, got %s", expected, url)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRenewalInfo_Disabled tests that ARI returns nil when disabled.
|
||||
func TestGetRenewalInfo_Disabled(t *testing.T) {
|
||||
config := &Config{
|
||||
DirectoryURL: "https://acme.invalid/directory",
|
||||
Email: "test@example.com",
|
||||
ChallengeType: "http-01",
|
||||
ARIEnabled: false,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
connector := New(config, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := connector.GetRenewalInfo(ctx, "any-cert-pem")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Error("GetRenewalInfo should return nil when ARI is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRenewalInfo_NotFound tests handling of 404 response (CA doesn't support ARI).
|
||||
func TestGetRenewalInfo_NotFound(t *testing.T) {
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Mock directory endpoint
|
||||
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"newOrder": "/acme/new-order",
|
||||
"newAccount": "/acme/new-account",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// All other endpoints return 404
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
config := &Config{
|
||||
DirectoryURL: mockServer.URL + "/directory",
|
||||
Email: "test@example.com",
|
||||
ChallengeType: "http-01",
|
||||
ARIEnabled: true,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
connector := New(config, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// GetRenewalInfo will fail when parsing the cert PEM, which is expected
|
||||
result, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
|
||||
if err == nil {
|
||||
// If it doesn't fail on cert parsing, that's also okay
|
||||
// The 404 handling happens after cert ID computation
|
||||
if result != nil {
|
||||
t.Error("GetRenewalInfo should return nil for 404 response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRenewalInfo_ServerError tests handling of server errors.
|
||||
func TestGetRenewalInfo_ServerError(t *testing.T) {
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Mock directory endpoint
|
||||
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"newOrder": "/acme/new-order",
|
||||
"newAccount": "/acme/new-account",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// All other endpoints return 500
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
config := &Config{
|
||||
DirectoryURL: mockServer.URL + "/directory",
|
||||
Email: "test@example.com",
|
||||
ChallengeType: "http-01",
|
||||
ARIEnabled: true,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
connector := New(config, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
|
||||
// Error is expected because cert parsing fails first
|
||||
if err == nil {
|
||||
// If we get here, the server error handling should catch it
|
||||
t.Error("expected error for invalid cert or 500 response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRenewalInfo_InvalidPEM tests handling of invalid PEM input.
|
||||
func TestGetRenewalInfo_InvalidPEM(t *testing.T) {
|
||||
config := &Config{
|
||||
DirectoryURL: "https://acme.invalid/directory",
|
||||
Email: "test@example.com",
|
||||
ChallengeType: "http-01",
|
||||
ARIEnabled: true,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
connector := New(config, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := connector.GetRenewalInfo(ctx, "invalid pem data")
|
||||
if err == nil {
|
||||
t.Error("GetRenewalInfo should return error for invalid PEM")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRenewalInfo_MalformedResponse tests handling of malformed JSON response.
|
||||
func TestGetRenewalInfo_MalformedResponse(t *testing.T) {
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Mock directory endpoint
|
||||
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"renewalInfo": "/acme/renewalInfo",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mock renewalInfo with malformed JSON
|
||||
if r.URL.Path != "/directory" && r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"suggestedWindow": invalid json}`))
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
config := &Config{
|
||||
DirectoryURL: mockServer.URL + "/directory",
|
||||
Email: "test@example.com",
|
||||
ChallengeType: "http-01",
|
||||
ARIEnabled: true,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
connector := New(config, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
|
||||
// Error is expected
|
||||
if err == nil {
|
||||
t.Error("GetRenewalInfo should return error for malformed response or invalid cert")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRenewalInfo_MissingWindow tests handling of missing suggestedWindow.
|
||||
func TestGetRenewalInfo_MissingWindow(t *testing.T) {
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Mock directory endpoint
|
||||
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"renewalInfo": "/acme/renewalInfo",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mock renewalInfo without suggestedWindow
|
||||
if r.URL.Path != "/directory" && r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
config := &Config{
|
||||
DirectoryURL: mockServer.URL + "/directory",
|
||||
Email: "test@example.com",
|
||||
ChallengeType: "http-01",
|
||||
ARIEnabled: true,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
connector := New(config, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
|
||||
// Error is expected due to invalid cert PEM
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid cert or missing window")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,18 @@ type Connector interface {
|
||||
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
|
||||
// Used by the EST /cacerts endpoint. Returns empty string if not available.
|
||||
GetCACertPEM(ctx context.Context) (string, error)
|
||||
|
||||
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9702 for a certificate.
|
||||
// certPEM is the PEM-encoded certificate. Returns nil, nil if the CA does not support ARI.
|
||||
GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error)
|
||||
}
|
||||
|
||||
// RenewalInfoResult holds the ACME ARI response from a CA.
|
||||
type RenewalInfoResult struct {
|
||||
SuggestedWindowStart time.Time
|
||||
SuggestedWindowEnd time.Time
|
||||
RetryAfter time.Time
|
||||
ExplanationURL string
|
||||
}
|
||||
|
||||
// IssuanceRequest contains the parameters for issuing a new certificate.
|
||||
|
||||
@@ -735,3 +735,8 @@ func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
}
|
||||
return c.caCertPEM, nil
|
||||
}
|
||||
|
||||
// GetRenewalInfo returns nil, nil as the Local CA does not support ACME Renewal Information (ARI).
|
||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -410,6 +410,11 @@ func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("custom CA connector does not provide CA certificate access")
|
||||
}
|
||||
|
||||
// GetRenewalInfo returns nil, nil as the custom CA connector does not support ACME Renewal Information (ARI).
|
||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// --- Helper Methods ---
|
||||
|
||||
// writeTempFile writes data to a temporary file and returns its path.
|
||||
|
||||
@@ -472,5 +472,10 @@ func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("step-ca serves its own CA certificate at /root; use step-ca's endpoint directly")
|
||||
}
|
||||
|
||||
// GetRenewalInfo returns nil, nil as step-ca does not support ACME Renewal Information (ARI).
|
||||
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Ensure Connector implements the issuer.Connector interface.
|
||||
var _ issuer.Connector = (*Connector)(nil)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// NotifierAdapter bridges the email.Connector (notifier.Connector interface) to the
|
||||
// service.Notifier interface used by the notification registry. This adapter allows
|
||||
// the existing email SMTP connector to be registered alongside Slack, Teams, etc.
|
||||
type NotifierAdapter struct {
|
||||
connector *Connector
|
||||
}
|
||||
|
||||
// NewNotifierAdapter wraps an email.Connector to implement service.Notifier.
|
||||
func NewNotifierAdapter(c *Connector) *NotifierAdapter {
|
||||
return &NotifierAdapter{connector: c}
|
||||
}
|
||||
|
||||
// Channel returns the notification channel identifier.
|
||||
func (a *NotifierAdapter) Channel() string {
|
||||
return "Email"
|
||||
}
|
||||
|
||||
// Send delivers a notification via SMTP email.
|
||||
// The recipient is the email address, subject is used as the email subject,
|
||||
// and body is the email body content.
|
||||
func (a *NotifierAdapter) Send(ctx context.Context, recipient string, subject string, body string) error {
|
||||
if recipient == "" {
|
||||
return fmt.Errorf("email: recipient address is required")
|
||||
}
|
||||
return a.connector.sendEmail(ctx, recipient, subject, body)
|
||||
}
|
||||
|
||||
// SendHTML delivers an HTML email notification via SMTP.
|
||||
// Used by the digest service for rich HTML digest emails.
|
||||
func (a *NotifierAdapter) SendHTML(ctx context.Context, recipient string, subject string, htmlBody string) error {
|
||||
if recipient == "" {
|
||||
return fmt.Errorf("email: recipient address is required")
|
||||
}
|
||||
return a.connector.sendHTMLEmail(ctx, recipient, subject, htmlBody)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNotifierAdapter_Channel(t *testing.T) {
|
||||
connector := New(&Config{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
FromAddress: "test@example.com",
|
||||
}, nil)
|
||||
adapter := NewNotifierAdapter(connector)
|
||||
|
||||
if adapter.Channel() != "Email" {
|
||||
t.Errorf("expected channel 'Email', got '%s'", adapter.Channel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierAdapter_Send_EmptyRecipient(t *testing.T) {
|
||||
connector := New(&Config{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
FromAddress: "test@example.com",
|
||||
}, nil)
|
||||
adapter := NewNotifierAdapter(connector)
|
||||
|
||||
err := adapter.Send(context.Background(), "", "test subject", "test body")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty recipient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifierAdapter_SendHTML_EmptyRecipient(t *testing.T) {
|
||||
connector := New(&Config{
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
FromAddress: "test@example.com",
|
||||
}, nil)
|
||||
adapter := NewNotifierAdapter(connector)
|
||||
|
||||
err := adapter.SendHTML(context.Background(), "", "test subject", "<html>test</html>")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty recipient")
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,73 @@ func (c *Connector) sendEmail(ctx context.Context, to, subject, body string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendHTMLEmail sends an HTML email message using the configured SMTP server.
|
||||
// Used by the digest service for rich HTML digest emails.
|
||||
func (c *Connector) sendHTMLEmail(ctx context.Context, to, subject, htmlBody string) error {
|
||||
addr := net.JoinHostPort(c.config.SMTPHost, strconv.Itoa(c.config.SMTPPort))
|
||||
|
||||
var auth smtp.Auth
|
||||
if c.config.Username != "" && c.config.Password != "" {
|
||||
auth = smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.SMTPHost)
|
||||
}
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
|
||||
if c.config.UseTLS {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: c.config.SMTPHost,
|
||||
}
|
||||
conn, err = tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect via TLS: %w", err)
|
||||
}
|
||||
} else {
|
||||
conn, err = net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, c.config.SMTPHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(c.config.FromAddress); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
defer wc.Close()
|
||||
|
||||
message := c.formatHTMLEmailMessage(c.config.FromAddress, to, subject, htmlBody)
|
||||
if _, err := wc.Write(message); err != nil {
|
||||
return fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Quit(); err != nil {
|
||||
return fmt.Errorf("failed to quit SMTP: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatEmailMessage formats an email message with standard headers.
|
||||
func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte {
|
||||
message := fmt.Sprintf(
|
||||
@@ -208,6 +275,19 @@ func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte {
|
||||
return []byte(message)
|
||||
}
|
||||
|
||||
// formatHTMLEmailMessage formats an HTML email message with MIME headers.
|
||||
func (c *Connector) formatHTMLEmailMessage(from, to, subject, htmlBody string) []byte {
|
||||
message := fmt.Sprintf(
|
||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
time.Now().Format(time.RFC1123Z),
|
||||
htmlBody,
|
||||
)
|
||||
return []byte(message)
|
||||
}
|
||||
|
||||
// formatAlertBody formats an alert notification as email body text.
|
||||
func (c *Connector) formatAlertBody(alert notifier.Alert) string {
|
||||
body := fmt.Sprintf(`
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// RenewalInfo represents ACME Renewal Information (ARI) per RFC 9702.
|
||||
// It provides CA-directed renewal timing via a suggested renewal window.
|
||||
type RenewalInfo struct {
|
||||
// SuggestedWindowStart is the beginning of the time window during which the CA suggests renewal.
|
||||
SuggestedWindowStart time.Time `json:"suggested_window_start"`
|
||||
|
||||
// SuggestedWindowEnd is the end of the time window during which the CA suggests renewal.
|
||||
SuggestedWindowEnd time.Time `json:"suggested_window_end"`
|
||||
|
||||
// RetryAfter is the earliest time the client should re-poll for updated ARI.
|
||||
// Zero value means no retry constraint.
|
||||
RetryAfter time.Time `json:"retry_after,omitempty"`
|
||||
|
||||
// ExplanationURL is an optional URL with human-readable explanation for the renewal timing.
|
||||
ExplanationURL string `json:"explanation_url,omitempty"`
|
||||
}
|
||||
|
||||
// ShouldRenewNow returns true if the current time is within or past the suggested renewal window.
|
||||
// This is the primary decision point: if true, renewal should proceed immediately.
|
||||
func (r *RenewalInfo) ShouldRenewNow() bool {
|
||||
now := time.Now()
|
||||
return !now.Before(r.SuggestedWindowStart)
|
||||
}
|
||||
|
||||
// OptimalRenewalTime returns the midpoint of the suggested renewal window,
|
||||
// which is the recommended time to initiate renewal per RFC 9702.
|
||||
// This can be used for scheduling if the current time is before the window.
|
||||
func (r *RenewalInfo) OptimalRenewalTime() time.Time {
|
||||
duration := r.SuggestedWindowEnd.Sub(r.SuggestedWindowStart)
|
||||
return r.SuggestedWindowStart.Add(duration / 2)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRenewalInfo_ShouldRenewNow_BeforeWindow(t *testing.T) {
|
||||
now := time.Now()
|
||||
windowStart := now.Add(1 * time.Hour)
|
||||
windowEnd := now.Add(2 * time.Hour)
|
||||
|
||||
ri := &RenewalInfo{
|
||||
SuggestedWindowStart: windowStart,
|
||||
SuggestedWindowEnd: windowEnd,
|
||||
}
|
||||
|
||||
if ri.ShouldRenewNow() {
|
||||
t.Error("ShouldRenewNow should be false before window start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalInfo_ShouldRenewNow_AtWindowStart(t *testing.T) {
|
||||
now := time.Now()
|
||||
windowStart := now
|
||||
windowEnd := now.Add(1 * time.Hour)
|
||||
|
||||
ri := &RenewalInfo{
|
||||
SuggestedWindowStart: windowStart,
|
||||
SuggestedWindowEnd: windowEnd,
|
||||
}
|
||||
|
||||
if !ri.ShouldRenewNow() {
|
||||
t.Error("ShouldRenewNow should be true at window start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalInfo_ShouldRenewNow_DuringWindow(t *testing.T) {
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-30 * time.Minute)
|
||||
windowEnd := now.Add(30 * time.Minute)
|
||||
|
||||
ri := &RenewalInfo{
|
||||
SuggestedWindowStart: windowStart,
|
||||
SuggestedWindowEnd: windowEnd,
|
||||
}
|
||||
|
||||
if !ri.ShouldRenewNow() {
|
||||
t.Error("ShouldRenewNow should be true during window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalInfo_ShouldRenewNow_AfterWindowEnd(t *testing.T) {
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-2 * time.Hour)
|
||||
windowEnd := now.Add(-1 * time.Hour)
|
||||
|
||||
ri := &RenewalInfo{
|
||||
SuggestedWindowStart: windowStart,
|
||||
SuggestedWindowEnd: windowEnd,
|
||||
}
|
||||
|
||||
if !ri.ShouldRenewNow() {
|
||||
t.Error("ShouldRenewNow should be true after window end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalInfo_OptimalRenewalTime_Midpoint(t *testing.T) {
|
||||
windowStart := time.Unix(1000, 0)
|
||||
windowEnd := time.Unix(3000, 0)
|
||||
|
||||
ri := &RenewalInfo{
|
||||
SuggestedWindowStart: windowStart,
|
||||
SuggestedWindowEnd: windowEnd,
|
||||
}
|
||||
|
||||
optimal := ri.OptimalRenewalTime()
|
||||
expected := time.Unix(2000, 0) // (1000 + 3000) / 2
|
||||
|
||||
if !optimal.Equal(expected) {
|
||||
t.Errorf("OptimalRenewalTime: expected %v, got %v", expected, optimal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalInfo_OptimalRenewalTime_AsymmetricWindow(t *testing.T) {
|
||||
windowStart := time.Unix(1000, 0)
|
||||
windowEnd := time.Unix(1300, 0) // 300 second window
|
||||
|
||||
ri := &RenewalInfo{
|
||||
SuggestedWindowStart: windowStart,
|
||||
SuggestedWindowEnd: windowEnd,
|
||||
}
|
||||
|
||||
optimal := ri.OptimalRenewalTime()
|
||||
expected := time.Unix(1150, 0) // start + 150 seconds
|
||||
|
||||
if !optimal.Equal(expected) {
|
||||
t.Errorf("OptimalRenewalTime: expected %v, got %v", expected, optimal)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func RegisterTools(s *gomcp.Server, client *Client) {
|
||||
registerNotificationTools(s, client)
|
||||
registerStatsTools(s, client)
|
||||
registerMetricsTools(s, client)
|
||||
registerDigestTools(s, client)
|
||||
registerHealthTools(s, client)
|
||||
}
|
||||
|
||||
@@ -1002,6 +1003,32 @@ func registerStatsTools(s *gomcp.Server, c *Client) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Digest ──────────────────────────────────────────────────────────
|
||||
|
||||
func registerDigestTools(s *gomcp.Server, c *Client) {
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_preview_digest",
|
||||
Description: "Preview the scheduled certificate digest email in HTML format. Shows summary of certificate status, pending jobs, and expiring certificates.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Get("/api/v1/digest/preview", nil)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_send_digest",
|
||||
Description: "Trigger immediate sending of the certificate digest email to configured recipients. If no explicit recipients are configured, sends to certificate owners.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Post("/api/v1/digest/send", nil)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Metrics ─────────────────────────────────────────────────────────
|
||||
|
||||
func registerMetricsTools(s *gomcp.Server, c *Client) {
|
||||
|
||||
@@ -35,6 +35,11 @@ type NetworkScanServicer interface {
|
||||
ScanAllTargets(ctx context.Context) error
|
||||
}
|
||||
|
||||
// DigestServicer defines the interface for digest email processing used by the scheduler.
|
||||
type DigestServicer interface {
|
||||
ProcessDigest(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Scheduler manages background jobs and periodic tasks for the certificate control plane.
|
||||
// It runs multiple concurrent loops for renewal checks, job processing, agent health checks,
|
||||
// and notification processing.
|
||||
@@ -44,6 +49,7 @@ type Scheduler struct {
|
||||
agentService AgentServicer
|
||||
notificationService NotificationServicer
|
||||
networkScanService NetworkScanServicer
|
||||
digestService DigestServicer
|
||||
logger *slog.Logger
|
||||
|
||||
// Configurable tick intervals
|
||||
@@ -53,6 +59,7 @@ type Scheduler struct {
|
||||
notificationProcessInterval time.Duration
|
||||
shortLivedExpiryCheckInterval time.Duration
|
||||
networkScanInterval time.Duration
|
||||
digestInterval time.Duration
|
||||
|
||||
// Idempotency guards: prevent duplicate execution of slow jobs
|
||||
renewalCheckRunning atomic.Bool
|
||||
@@ -61,6 +68,7 @@ type Scheduler struct {
|
||||
notificationProcessRunning atomic.Bool
|
||||
shortLivedExpiryCheckRunning atomic.Bool
|
||||
networkScanRunning atomic.Bool
|
||||
digestRunning atomic.Bool
|
||||
|
||||
// Graceful shutdown: wait for in-flight work to complete
|
||||
wg sync.WaitGroup
|
||||
@@ -90,9 +98,21 @@ func NewScheduler(
|
||||
notificationProcessInterval: 1 * time.Minute,
|
||||
shortLivedExpiryCheckInterval: 30 * time.Second,
|
||||
networkScanInterval: 6 * time.Hour,
|
||||
digestInterval: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDigestService sets the digest service for the 7th scheduler loop.
|
||||
// Called after construction since digest is optional.
|
||||
func (s *Scheduler) SetDigestService(ds DigestServicer) {
|
||||
s.digestService = ds
|
||||
}
|
||||
|
||||
// SetDigestInterval configures the interval for digest email processing.
|
||||
func (s *Scheduler) SetDigestInterval(d time.Duration) {
|
||||
s.digestInterval = d
|
||||
}
|
||||
|
||||
// SetRenewalCheckInterval configures the interval for renewal checks.
|
||||
func (s *Scheduler) SetRenewalCheckInterval(d time.Duration) {
|
||||
s.renewalCheckInterval = d
|
||||
@@ -135,7 +155,10 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
|
||||
// blocks until they've fully exited (prevents test races).
|
||||
loopCount := 5
|
||||
if s.networkScanService != nil {
|
||||
loopCount = 6
|
||||
loopCount++
|
||||
}
|
||||
if s.digestService != nil {
|
||||
loopCount++
|
||||
}
|
||||
s.wg.Add(loopCount)
|
||||
|
||||
@@ -147,6 +170,9 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
|
||||
if s.networkScanService != nil {
|
||||
go func() { defer s.wg.Done(); s.networkScanLoop(ctx) }()
|
||||
}
|
||||
if s.digestService != nil {
|
||||
go func() { defer s.wg.Done(); s.digestLoop(ctx) }()
|
||||
}
|
||||
|
||||
// Signal that all loops are launched
|
||||
close(startedChan)
|
||||
@@ -450,6 +476,47 @@ func (s *Scheduler) runNetworkScan(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// digestLoop runs every digestInterval and generates/sends certificate digest emails.
|
||||
// Uses atomic.Bool to prevent duplicate execution if the previous digest is still running.
|
||||
func (s *Scheduler) digestLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(s.digestInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Do NOT run immediately on start for digest — wait for the first tick.
|
||||
// Digests are infrequent (24h default) and shouldn't fire on every restart.
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if !s.digestRunning.CompareAndSwap(false, true) {
|
||||
s.logger.Warn("digest processor still running, skipping tick")
|
||||
continue
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
defer s.digestRunning.Store(false)
|
||||
s.runDigest(ctx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runDigest executes a single digest processing cycle with error recovery.
|
||||
func (s *Scheduler) runDigest(ctx context.Context) {
|
||||
opCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := s.digestService.ProcessDigest(opCtx); err != nil {
|
||||
s.logger.Error("digest processor failed",
|
||||
"error", err,
|
||||
"interval", s.digestInterval.String())
|
||||
} else {
|
||||
s.logger.Debug("digest processor completed")
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForCompletion waits for all in-flight scheduler work to complete.
|
||||
// It respects the provided timeout and returns an error if work is still in progress after timeout.
|
||||
// Call this after the scheduler context has been cancelled to ensure graceful shutdown.
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// DigestService generates and sends periodic certificate digest emails.
|
||||
// It aggregates statistics from StatsService and sends HTML-formatted
|
||||
// summary emails to configured recipients.
|
||||
type DigestService struct {
|
||||
statsService *StatsService
|
||||
certRepo repository.CertificateRepository
|
||||
ownerRepo repository.OwnerRepository
|
||||
emailSender HTMLEmailSender
|
||||
recipients []string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// HTMLEmailSender defines the interface for sending HTML emails.
|
||||
// Implemented by the email notifier adapter.
|
||||
type HTMLEmailSender interface {
|
||||
SendHTML(ctx context.Context, recipient string, subject string, htmlBody string) error
|
||||
}
|
||||
|
||||
// DigestData holds the aggregated data for a digest email.
|
||||
type DigestData struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
TotalCertificates int64 `json:"total_certificates"`
|
||||
ExpiringCertificates int64 `json:"expiring_certificates"`
|
||||
ExpiredCertificates int64 `json:"expired_certificates"`
|
||||
RevokedCertificates int64 `json:"revoked_certificates"`
|
||||
ActiveAgents int64 `json:"active_agents"`
|
||||
OfflineAgents int64 `json:"offline_agents"`
|
||||
TotalAgents int64 `json:"total_agents"`
|
||||
PendingJobs int64 `json:"pending_jobs"`
|
||||
FailedJobs int64 `json:"failed_jobs"`
|
||||
CompletedJobs int64 `json:"completed_jobs"`
|
||||
ExpiringCerts []DigestCertEntry `json:"expiring_certs"`
|
||||
RecentFailures []DigestJobEntry `json:"recent_failures"`
|
||||
StatusCounts []DigestStatusCount `json:"status_counts"`
|
||||
}
|
||||
|
||||
// DigestCertEntry represents a certificate entry in the digest.
|
||||
type DigestCertEntry struct {
|
||||
ID string `json:"id"`
|
||||
CommonName string `json:"common_name"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
}
|
||||
|
||||
// DigestJobEntry represents a failed job entry in the digest.
|
||||
type DigestJobEntry struct {
|
||||
ID string `json:"id"`
|
||||
CertificateID string `json:"certificate_id"`
|
||||
Type string `json:"type"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// DigestStatusCount represents certificate counts by status for the digest.
|
||||
type DigestStatusCount struct {
|
||||
Status string `json:"status"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// NewDigestService creates a new digest service.
|
||||
func NewDigestService(
|
||||
statsService *StatsService,
|
||||
certRepo repository.CertificateRepository,
|
||||
ownerRepo repository.OwnerRepository,
|
||||
emailSender HTMLEmailSender,
|
||||
recipients []string,
|
||||
logger *slog.Logger,
|
||||
) *DigestService {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &DigestService{
|
||||
statsService: statsService,
|
||||
certRepo: certRepo,
|
||||
ownerRepo: ownerRepo,
|
||||
emailSender: emailSender,
|
||||
recipients: recipients,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateDigest aggregates current system statistics into a DigestData struct.
|
||||
func (s *DigestService) GenerateDigest(ctx context.Context) (*DigestData, error) {
|
||||
digest := &DigestData{
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Get dashboard summary
|
||||
summaryRaw, err := s.statsService.GetDashboardSummary(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dashboard summary: %w", err)
|
||||
}
|
||||
if summary, ok := summaryRaw.(*DashboardSummary); ok {
|
||||
digest.TotalCertificates = summary.TotalCertificates
|
||||
digest.ExpiringCertificates = summary.ExpiringCertificates
|
||||
digest.ExpiredCertificates = summary.ExpiredCertificates
|
||||
digest.RevokedCertificates = summary.RevokedCertificates
|
||||
digest.ActiveAgents = summary.ActiveAgents
|
||||
digest.OfflineAgents = summary.OfflineAgents
|
||||
digest.TotalAgents = summary.TotalAgents
|
||||
digest.PendingJobs = summary.PendingJobs
|
||||
digest.FailedJobs = summary.FailedJobs
|
||||
digest.CompletedJobs = summary.CompleteJobs
|
||||
}
|
||||
|
||||
// Get certificates by status
|
||||
statusRaw, err := s.statsService.GetCertificatesByStatus(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get status counts for digest", "error", err)
|
||||
} else if counts, ok := statusRaw.([]CertificateStatusCount); ok {
|
||||
for _, c := range counts {
|
||||
digest.StatusCounts = append(digest.StatusCounts, DigestStatusCount{
|
||||
Status: c.Status,
|
||||
Count: c.Count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get expiring certificates (next 30 days)
|
||||
now := time.Now()
|
||||
thirtyDaysFromNow := now.AddDate(0, 0, 30)
|
||||
allCerts, _, err := s.certRepo.List(ctx, &repository.CertificateFilter{Page: 1, PerPage: 10000})
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to list certs for digest", "error", err)
|
||||
} else {
|
||||
for _, cert := range allCerts {
|
||||
if !cert.ExpiresAt.IsZero() && cert.ExpiresAt.After(now) && cert.ExpiresAt.Before(thirtyDaysFromNow) {
|
||||
daysLeft := int(time.Until(cert.ExpiresAt).Hours() / 24)
|
||||
digest.ExpiringCerts = append(digest.ExpiringCerts, DigestCertEntry{
|
||||
ID: cert.ID,
|
||||
CommonName: cert.CommonName,
|
||||
ExpiresAt: cert.ExpiresAt,
|
||||
DaysLeft: daysLeft,
|
||||
OwnerID: cert.OwnerID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// SendDigest generates a digest and sends it to all configured recipients.
|
||||
func (s *DigestService) SendDigest(ctx context.Context) error {
|
||||
if s.emailSender == nil {
|
||||
return fmt.Errorf("email sender not configured — set CERTCTL_SMTP_HOST and CERTCTL_SMTP_FROM_ADDRESS")
|
||||
}
|
||||
|
||||
digest, err := s.GenerateDigest(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate digest: %w", err)
|
||||
}
|
||||
|
||||
htmlBody, err := s.RenderDigestHTML(digest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render digest HTML: %w", err)
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("certctl Certificate Digest — %s", digest.GeneratedAt.Format("2006-01-02"))
|
||||
|
||||
recipients := s.recipients
|
||||
if len(recipients) == 0 {
|
||||
// Fall back to owner emails
|
||||
recipients = s.resolveOwnerEmails(ctx)
|
||||
}
|
||||
|
||||
if len(recipients) == 0 {
|
||||
s.logger.Warn("no digest recipients configured and no owner emails found")
|
||||
return nil
|
||||
}
|
||||
|
||||
var sendErrors int
|
||||
for _, recipient := range recipients {
|
||||
if err := s.emailSender.SendHTML(ctx, recipient, subject, htmlBody); err != nil {
|
||||
s.logger.Error("failed to send digest to recipient",
|
||||
"recipient", recipient,
|
||||
"error", err)
|
||||
sendErrors++
|
||||
} else {
|
||||
s.logger.Info("digest email sent", "recipient", recipient)
|
||||
}
|
||||
}
|
||||
|
||||
if sendErrors > 0 {
|
||||
return fmt.Errorf("failed to send digest to %d of %d recipients", sendErrors, len(recipients))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessDigest is the scheduler-facing method. It generates and sends the digest,
|
||||
// logging errors rather than propagating them to match the scheduler pattern.
|
||||
func (s *DigestService) ProcessDigest(ctx context.Context) error {
|
||||
return s.SendDigest(ctx)
|
||||
}
|
||||
|
||||
// RenderDigestHTML renders the digest data into an HTML email body.
|
||||
func (s *DigestService) RenderDigestHTML(data *DigestData) (string, error) {
|
||||
tmpl, err := template.New("digest").Parse(digestHTMLTemplate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse digest template: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("failed to execute digest template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// PreviewDigest generates and renders a digest without sending it.
|
||||
// Used by the API handler for preview endpoints.
|
||||
func (s *DigestService) PreviewDigest(ctx context.Context) (string, error) {
|
||||
digest, err := s.GenerateDigest(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate digest: %w", err)
|
||||
}
|
||||
|
||||
return s.RenderDigestHTML(digest)
|
||||
}
|
||||
|
||||
// resolveOwnerEmails collects unique email addresses from all certificate owners.
|
||||
func (s *DigestService) resolveOwnerEmails(ctx context.Context) []string {
|
||||
if s.ownerRepo == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
owners, err := s.ownerRepo.List(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to list owners for digest recipients", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var emails []string
|
||||
for _, owner := range owners {
|
||||
if owner.Email != "" && !seen[owner.Email] {
|
||||
seen[owner.Email] = true
|
||||
emails = append(emails, owner.Email)
|
||||
}
|
||||
}
|
||||
|
||||
return emails
|
||||
}
|
||||
|
||||
// digestHTMLTemplate is the HTML template for the certificate digest email.
|
||||
const digestHTMLTemplate = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>certctl Certificate Digest</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 640px; margin: 0 auto; background: #fff; }
|
||||
.header { background: #1a1a2e; color: #fff; padding: 24px 32px; }
|
||||
.header h1 { margin: 0; font-size: 22px; font-weight: 600; }
|
||||
.header .date { color: #a0a0b0; font-size: 13px; margin-top: 4px; }
|
||||
.section { padding: 24px 32px; border-bottom: 1px solid #eee; }
|
||||
.section h2 { font-size: 16px; font-weight: 600; margin: 0 0 16px 0; color: #1a1a2e; }
|
||||
.stats-grid { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.stat-card { flex: 1; min-width: 120px; background: #f8f9fa; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #1a1a2e; }
|
||||
.stat-label { font-size: 12px; color: #666; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-warn .stat-value { color: #e67e22; }
|
||||
.stat-danger .stat-value { color: #e74c3c; }
|
||||
.stat-success .stat-value { color: #27ae60; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; padding: 8px 12px; background: #f8f9fa; color: #666; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.badge-warn { background: #fef3e2; color: #e67e22; }
|
||||
.badge-danger { background: #fde8e8; color: #e74c3c; }
|
||||
.badge-ok { background: #e8f8ef; color: #27ae60; }
|
||||
.footer { padding: 20px 32px; text-align: center; color: #999; font-size: 12px; background: #f8f9fa; }
|
||||
.empty-state { text-align: center; padding: 24px; color: #999; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>certctl Certificate Digest</h1>
|
||||
<div class="date">Generated: {{.GeneratedAt.Format "January 2, 2006 3:04 PM"}}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>System Overview</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{.TotalCertificates}}</div>
|
||||
<div class="stat-label">Total Certs</div>
|
||||
</div>
|
||||
<div class="stat-card stat-warn">
|
||||
<div class="stat-value">{{.ExpiringCertificates}}</div>
|
||||
<div class="stat-label">Expiring</div>
|
||||
</div>
|
||||
<div class="stat-card stat-danger">
|
||||
<div class="stat-value">{{.ExpiredCertificates}}</div>
|
||||
<div class="stat-label">Expired</div>
|
||||
</div>
|
||||
<div class="stat-card stat-success">
|
||||
<div class="stat-value">{{.ActiveAgents}}</div>
|
||||
<div class="stat-label">Active Agents</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Jobs Summary</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{.PendingJobs}}</div>
|
||||
<div class="stat-label">Pending</div>
|
||||
</div>
|
||||
<div class="stat-card stat-danger">
|
||||
<div class="stat-value">{{.FailedJobs}}</div>
|
||||
<div class="stat-label">Failed</div>
|
||||
</div>
|
||||
<div class="stat-card stat-success">
|
||||
<div class="stat-value">{{.CompletedJobs}}</div>
|
||||
<div class="stat-label">Completed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ExpiringCerts}}
|
||||
<div class="section">
|
||||
<h2>Certificates Expiring Soon</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Common Name</th><th>Expires</th><th>Days Left</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .ExpiringCerts}}
|
||||
<tr>
|
||||
<td>{{.CommonName}}</td>
|
||||
<td>{{.ExpiresAt.Format "Jan 2, 2006"}}</td>
|
||||
<td>
|
||||
{{if le .DaysLeft 7}}<span class="badge badge-danger">{{.DaysLeft}} days</span>
|
||||
{{else if le .DaysLeft 14}}<span class="badge badge-warn">{{.DaysLeft}} days</span>
|
||||
{{else}}<span class="badge badge-ok">{{.DaysLeft}} days</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="section">
|
||||
<h2>Certificates Expiring Soon</h2>
|
||||
<div class="empty-state">No certificates expiring in the next 30 days.</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="footer">
|
||||
This digest was automatically generated by certctl.<br>
|
||||
Configure digest settings with CERTCTL_DIGEST_* environment variables.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,309 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// mockHTMLEmailSender implements HTMLEmailSender for testing.
|
||||
type mockHTMLEmailSender struct {
|
||||
sentEmails []sentHTMLEmail
|
||||
sendErr error
|
||||
}
|
||||
|
||||
type sentHTMLEmail struct {
|
||||
recipient string
|
||||
subject string
|
||||
body string
|
||||
}
|
||||
|
||||
func (m *mockHTMLEmailSender) SendHTML(ctx context.Context, recipient string, subject string, htmlBody string) error {
|
||||
if m.sendErr != nil {
|
||||
return m.sendErr
|
||||
}
|
||||
m.sentEmails = append(m.sentEmails, sentHTMLEmail{
|
||||
recipient: recipient,
|
||||
subject: subject,
|
||||
body: htmlBody,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDigestService_GenerateDigest(t *testing.T) {
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
|
||||
// Add test certificates
|
||||
now := time.Now()
|
||||
certRepo.Certs["cert-1"] = &domain.ManagedCertificate{
|
||||
ID: "cert-1",
|
||||
CommonName: "example.com",
|
||||
ExpiresAt: now.AddDate(0, 0, 10),
|
||||
OwnerID: "owner-1",
|
||||
}
|
||||
certRepo.Certs["cert-2"] = &domain.ManagedCertificate{
|
||||
ID: "cert-2",
|
||||
CommonName: "api.example.com",
|
||||
ExpiresAt: now.AddDate(0, 0, 25),
|
||||
OwnerID: "owner-2",
|
||||
}
|
||||
certRepo.Certs["cert-3"] = &domain.ManagedCertificate{
|
||||
ID: "cert-3",
|
||||
CommonName: "old.example.com",
|
||||
ExpiresAt: now.AddDate(0, 0, -5), // expired
|
||||
OwnerID: "owner-1",
|
||||
}
|
||||
|
||||
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
|
||||
|
||||
sender := &mockHTMLEmailSender{}
|
||||
digestService := NewDigestService(statsService, certRepo, nil, sender, []string{"admin@example.com"}, nil)
|
||||
|
||||
digest, err := digestService.GenerateDigest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDigest failed: %v", err)
|
||||
}
|
||||
|
||||
if digest.TotalCertificates != 3 {
|
||||
t.Errorf("expected 3 total certs, got %d", digest.TotalCertificates)
|
||||
}
|
||||
|
||||
if len(digest.ExpiringCerts) != 2 {
|
||||
t.Errorf("expected 2 expiring certs (10d and 25d), got %d", len(digest.ExpiringCerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestService_GenerateDigest_Empty(t *testing.T) {
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
|
||||
|
||||
sender := &mockHTMLEmailSender{}
|
||||
digestService := NewDigestService(statsService, certRepo, nil, sender, nil, nil)
|
||||
|
||||
digest, err := digestService.GenerateDigest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDigest failed: %v", err)
|
||||
}
|
||||
|
||||
if digest.TotalCertificates != 0 {
|
||||
t.Errorf("expected 0 total certs, got %d", digest.TotalCertificates)
|
||||
}
|
||||
|
||||
if len(digest.ExpiringCerts) != 0 {
|
||||
t.Errorf("expected 0 expiring certs, got %d", len(digest.ExpiringCerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestService_RenderDigestHTML(t *testing.T) {
|
||||
digestService := &DigestService{}
|
||||
|
||||
data := &DigestData{
|
||||
GeneratedAt: time.Now(),
|
||||
TotalCertificates: 42,
|
||||
ExpiringCertificates: 5,
|
||||
ExpiredCertificates: 2,
|
||||
ActiveAgents: 3,
|
||||
PendingJobs: 1,
|
||||
ExpiringCerts: []DigestCertEntry{
|
||||
{ID: "c1", CommonName: "example.com", ExpiresAt: time.Now().AddDate(0, 0, 5), DaysLeft: 5},
|
||||
},
|
||||
}
|
||||
|
||||
html, err := digestService.RenderDigestHTML(data)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderDigestHTML failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(html, "certctl Certificate Digest") {
|
||||
t.Error("expected HTML to contain 'certctl Certificate Digest'")
|
||||
}
|
||||
|
||||
if !strings.Contains(html, "42") {
|
||||
t.Error("expected HTML to contain total certificate count '42'")
|
||||
}
|
||||
|
||||
if !strings.Contains(html, "example.com") {
|
||||
t.Error("expected HTML to contain 'example.com'")
|
||||
}
|
||||
|
||||
if !strings.Contains(html, "5 days") {
|
||||
t.Error("expected HTML to contain '5 days'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestService_RenderDigestHTML_Empty(t *testing.T) {
|
||||
digestService := &DigestService{}
|
||||
|
||||
data := &DigestData{
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
|
||||
html, err := digestService.RenderDigestHTML(data)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderDigestHTML failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(html, "No certificates expiring in the next 30 days") {
|
||||
t.Error("expected empty state message in HTML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestService_SendDigest_Success(t *testing.T) {
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
|
||||
|
||||
sender := &mockHTMLEmailSender{}
|
||||
recipients := []string{"admin@example.com", "ops@example.com"}
|
||||
digestService := NewDigestService(statsService, certRepo, nil, sender, recipients, nil)
|
||||
|
||||
err := digestService.SendDigest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("SendDigest failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sender.sentEmails) != 2 {
|
||||
t.Fatalf("expected 2 emails sent, got %d", len(sender.sentEmails))
|
||||
}
|
||||
|
||||
if sender.sentEmails[0].recipient != "admin@example.com" {
|
||||
t.Errorf("expected first recipient admin@example.com, got %s", sender.sentEmails[0].recipient)
|
||||
}
|
||||
|
||||
if !strings.Contains(sender.sentEmails[0].subject, "certctl Certificate Digest") {
|
||||
t.Errorf("expected subject to contain 'certctl Certificate Digest', got %s", sender.sentEmails[0].subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestService_SendDigest_NoSender(t *testing.T) {
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
|
||||
|
||||
digestService := NewDigestService(statsService, certRepo, nil, nil, []string{"admin@example.com"}, nil)
|
||||
|
||||
err := digestService.SendDigest(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when sender is nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "email sender not configured") {
|
||||
t.Errorf("expected 'email sender not configured' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestService_SendDigest_SendError(t *testing.T) {
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
|
||||
|
||||
sender := &mockHTMLEmailSender{sendErr: errors.New("SMTP connection refused")}
|
||||
digestService := NewDigestService(statsService, certRepo, nil, sender, []string{"admin@example.com"}, nil)
|
||||
|
||||
err := digestService.SendDigest(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when send fails")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "failed to send digest") {
|
||||
t.Errorf("expected 'failed to send digest' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestService_SendDigest_NoRecipients(t *testing.T) {
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
|
||||
|
||||
sender := &mockHTMLEmailSender{}
|
||||
// No explicit recipients and no owner repo
|
||||
digestService := NewDigestService(statsService, certRepo, nil, sender, nil, nil)
|
||||
|
||||
err := digestService.SendDigest(context.Background())
|
||||
// Should succeed without error (just no recipients)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(sender.sentEmails) != 0 {
|
||||
t.Errorf("expected 0 emails sent, got %d", len(sender.sentEmails))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestService_PreviewDigest(t *testing.T) {
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
|
||||
|
||||
sender := &mockHTMLEmailSender{}
|
||||
digestService := NewDigestService(statsService, certRepo, nil, sender, nil, nil)
|
||||
|
||||
html, err := digestService.PreviewDigest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("PreviewDigest failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(html, "<!DOCTYPE html>") {
|
||||
t.Error("expected valid HTML document")
|
||||
}
|
||||
|
||||
if !strings.Contains(html, "certctl Certificate Digest") {
|
||||
t.Error("expected HTML to contain 'certctl Certificate Digest'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestService_ProcessDigest(t *testing.T) {
|
||||
certRepo := &mockCertRepo{
|
||||
Certs: make(map[string]*domain.ManagedCertificate),
|
||||
Versions: make(map[string][]*domain.CertificateVersion),
|
||||
}
|
||||
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
|
||||
|
||||
sender := &mockHTMLEmailSender{}
|
||||
digestService := NewDigestService(statsService, certRepo, nil, sender, []string{"test@example.com"}, nil)
|
||||
|
||||
err := digestService.ProcessDigest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDigest failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sender.sentEmails) != 1 {
|
||||
t.Errorf("expected 1 email sent, got %d", len(sender.sentEmails))
|
||||
}
|
||||
}
|
||||
@@ -102,3 +102,20 @@ func (a *IssuerConnectorAdapter) SignOCSPResponse(ctx context.Context, req OCSPS
|
||||
func (a *IssuerConnectorAdapter) GetCACertPEM(ctx context.Context) (string, error) {
|
||||
return a.connector.GetCACertPEM(ctx)
|
||||
}
|
||||
|
||||
// GetRenewalInfo delegates to the underlying connector, translating between service-layer and connector-layer types.
|
||||
func (a *IssuerConnectorAdapter) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
|
||||
result, err := a.connector.GetRenewalInfo(ctx, certPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &RenewalInfoResult{
|
||||
SuggestedWindowStart: result.SuggestedWindowStart,
|
||||
SuggestedWindowEnd: result.SuggestedWindowEnd,
|
||||
RetryAfter: result.RetryAfter,
|
||||
ExplanationURL: result.ExplanationURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -13,16 +13,19 @@ import (
|
||||
|
||||
// mockConnectorLayerIssuer is a test implementation of issuer.Connector
|
||||
type mockConnectorLayerIssuer struct {
|
||||
issueResult *issuer.IssuanceResult
|
||||
issueErr error
|
||||
renewResult *issuer.IssuanceResult
|
||||
renewErr error
|
||||
lastIssueReq *issuer.IssuanceRequest
|
||||
lastRenewReq *issuer.RenewalRequest
|
||||
validateErr error
|
||||
revokeErr error
|
||||
orderStatusErr error
|
||||
orderStatus *issuer.OrderStatus
|
||||
issueResult *issuer.IssuanceResult
|
||||
issueErr error
|
||||
renewResult *issuer.IssuanceResult
|
||||
renewErr error
|
||||
lastIssueReq *issuer.IssuanceRequest
|
||||
lastRenewReq *issuer.RenewalRequest
|
||||
validateErr error
|
||||
revokeErr error
|
||||
orderStatusErr error
|
||||
orderStatus *issuer.OrderStatus
|
||||
renewalInfoResult *issuer.RenewalInfoResult
|
||||
renewalInfoErr error
|
||||
renewalInfoNil bool // flag to force nil result
|
||||
}
|
||||
|
||||
func (m *mockConnectorLayerIssuer) ValidateConfig(ctx context.Context, config json.RawMessage) error {
|
||||
@@ -100,6 +103,23 @@ func (m *mockConnectorLayerIssuer) GetCACertPEM(ctx context.Context) (string, er
|
||||
return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil
|
||||
}
|
||||
|
||||
func (m *mockConnectorLayerIssuer) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||
if m.renewalInfoErr != nil {
|
||||
return nil, m.renewalInfoErr
|
||||
}
|
||||
if m.renewalInfoNil {
|
||||
return nil, nil
|
||||
}
|
||||
if m.renewalInfoResult != nil {
|
||||
return m.renewalInfoResult, nil
|
||||
}
|
||||
now := time.Now()
|
||||
return &issuer.RenewalInfoResult{
|
||||
SuggestedWindowStart: now,
|
||||
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Tests for IssueCertificate
|
||||
|
||||
func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) {
|
||||
@@ -527,3 +547,102 @@ func TestIssuerConnectorAdapter_SignOCSPResponse_Unknown(t *testing.T) {
|
||||
|
||||
t.Log("OCSP response for unknown cert signed via adapter")
|
||||
}
|
||||
|
||||
// Tests for GetRenewalInfo
|
||||
|
||||
func TestIssuerConnectorAdapter_GetRenewalInfo_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
mock := &mockConnectorLayerIssuer{}
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
testCertPEM := "-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----"
|
||||
|
||||
result, err := adapter.GetRenewalInfo(ctx, testCertPEM)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
|
||||
if result.SuggestedWindowStart.IsZero() {
|
||||
t.Error("SuggestedWindowStart should not be zero")
|
||||
}
|
||||
|
||||
if result.SuggestedWindowEnd.IsZero() {
|
||||
t.Error("SuggestedWindowEnd should not be zero")
|
||||
}
|
||||
|
||||
if result.SuggestedWindowEnd.Before(result.SuggestedWindowStart) {
|
||||
t.Error("SuggestedWindowEnd should be after SuggestedWindowStart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerConnectorAdapter_GetRenewalInfo_Nil(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
mock := &mockConnectorLayerIssuer{
|
||||
renewalInfoNil: true,
|
||||
}
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
result, err := adapter.GetRenewalInfo(ctx, "test-cert-pem")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Error("expected nil result when underlying connector returns nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerConnectorAdapter_GetRenewalInfo_ResultTranslation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
windowStart := now
|
||||
windowEnd := now.Add(24 * time.Hour)
|
||||
retryAfter := now.Add(1 * time.Hour)
|
||||
explanationURL := "https://example.com/renewal-info"
|
||||
|
||||
mock := &mockConnectorLayerIssuer{
|
||||
renewalInfoResult: &issuer.RenewalInfoResult{
|
||||
SuggestedWindowStart: windowStart,
|
||||
SuggestedWindowEnd: windowEnd,
|
||||
RetryAfter: retryAfter,
|
||||
ExplanationURL: explanationURL,
|
||||
},
|
||||
}
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
result, err := adapter.GetRenewalInfo(ctx, "test-cert-pem")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("GetRenewalInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
|
||||
if !result.SuggestedWindowStart.Equal(windowStart) {
|
||||
t.Errorf("expected SuggestedWindowStart %v, got %v", windowStart, result.SuggestedWindowStart)
|
||||
}
|
||||
|
||||
if !result.SuggestedWindowEnd.Equal(windowEnd) {
|
||||
t.Errorf("expected SuggestedWindowEnd %v, got %v", windowEnd, result.SuggestedWindowEnd)
|
||||
}
|
||||
|
||||
if !result.RetryAfter.Equal(retryAfter) {
|
||||
t.Errorf("expected RetryAfter %v, got %v", retryAfter, result.RetryAfter)
|
||||
}
|
||||
|
||||
if result.ExplanationURL != explanationURL {
|
||||
t.Errorf("expected ExplanationURL %s, got %s", explanationURL, result.ExplanationURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,17 @@ type IssuerConnector interface {
|
||||
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
|
||||
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
|
||||
GetCACertPEM(ctx context.Context) (string, error)
|
||||
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9702 for a certificate.
|
||||
// certPEM is the PEM-encoded certificate. Returns nil, nil if the issuer does not support ARI.
|
||||
GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error)
|
||||
}
|
||||
|
||||
// RenewalInfoResult holds the ARI response from a CA.
|
||||
type RenewalInfoResult struct {
|
||||
SuggestedWindowStart time.Time
|
||||
SuggestedWindowEnd time.Time
|
||||
RetryAfter time.Time
|
||||
ExplanationURL string
|
||||
}
|
||||
|
||||
// IssuanceResult holds the result of a certificate issuance or renewal operation.
|
||||
|
||||
@@ -716,6 +716,17 @@ func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error)
|
||||
return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
}
|
||||
now := time.Now()
|
||||
return &RenewalInfoResult{
|
||||
SuggestedWindowStart: now,
|
||||
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Constructor functions for mocks
|
||||
|
||||
func newMockCertificateRepository() *mockCertRepo {
|
||||
|
||||
Reference in New Issue
Block a user