mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:11:29 +00:00
8b75e0311b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
374 lines
13 KiB
Go
374 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/certctl-io/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>`
|