mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:11:32 +00:00
7cb453a336
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.
Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.
The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
374 lines
13 KiB
Go
374 lines
13 KiB
Go
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(c))
|
|
}
|
|
}
|
|
|
|
// 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>`
|