Files
certctl/internal/service/renewal.go
T
2026-03-14 08:22:17 -04:00

226 lines
7.4 KiB
Go

package service
import (
"context"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// RenewalService manages certificate renewal workflows.
type RenewalService struct {
certRepo repository.CertificateRepository
jobRepo repository.JobRepository
auditService *AuditService
notificationSvc *NotificationService
issuerRegistry map[string]IssuerConnector
}
// IssuerConnector defines the interface for interacting with certificate issuers.
type IssuerConnector interface {
// RenewCertificate renews a certificate and returns the new certificate PEM.
RenewCertificate(ctx context.Context, csr []byte) ([]byte, error)
// GetCertificateChain returns the issuer's certificate chain.
GetCertificateChain(ctx context.Context) ([]byte, error)
}
// NewRenewalService creates a new renewal service.
func NewRenewalService(
certRepo repository.CertificateRepository,
jobRepo repository.JobRepository,
auditService *AuditService,
notificationSvc *NotificationService,
issuerRegistry map[string]IssuerConnector,
) *RenewalService {
return &RenewalService{
certRepo: certRepo,
jobRepo: jobRepo,
auditService: auditService,
notificationSvc: notificationSvc,
issuerRegistry: issuerRegistry,
}
}
// CheckExpiringCertificates identifies certificates needing renewal based on policy windows.
func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
// Default renewal window: 30 days before expiry
renewalWindow := time.Now().AddDate(0, 0, 30)
expiring, err := s.certRepo.GetExpiringCertificates(ctx, renewalWindow)
if err != nil {
return fmt.Errorf("failed to fetch expiring certificates: %w", err)
}
for _, cert := range expiring {
// Skip if already renewing or archived
if cert.Status == domain.CertificateStatusRenewalInProgress || cert.Status == domain.CertificateStatusArchived {
continue
}
// Calculate days until expiry
daysUntil := time.Until(cert.ExpiresAt).Hours() / 24
// Create renewal job
job := &domain.Job{
ID: generateID("job"),
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusPending,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
if err := s.jobRepo.Create(ctx, job); err != nil {
fmt.Printf("failed to create renewal job for cert %s: %v\n", cert.ID, err)
continue
}
// Send expiration warning notification
if err := s.notificationSvc.SendExpirationWarning(ctx, cert, int(daysUntil)); err != nil {
fmt.Printf("failed to send expiration warning for cert %s: %v\n", cert.ID, err)
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"renewal_job_created", "certificate", cert.ID,
map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID})
}
return nil
}
// ProcessRenewalJob executes a renewal job: call issuer, store new version, update cert status.
func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job) error {
// Update job status to in-progress
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusRunning, ""); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Fetch certificate
cert, err := s.certRepo.Get(ctx, job.CertificateID)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("certificate fetch failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to fetch certificate: %w", err)
}
// Get issuer connector
issuerID := cert.IssuerID
if issuerID == "" {
return fmt.Errorf("certificate has no issuer assigned")
}
connector, ok := s.issuerRegistry[issuerID]
if !ok {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed,
fmt.Sprintf("issuer connector not found for %s", issuerID))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("issuer connector not found for %s", issuerID)
}
// TODO: In production, fetch CSR from agent or generate new CSR
// For now, we'd use cert.CSR or generate a new one from the private key
csr := []byte{} // placeholder
// Call issuer to renew
certPEM, err := connector.RenewCertificate(ctx, csr)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("issuer renewal failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
// Send failure notification
_ = s.notificationSvc.SendRenewalNotification(ctx, cert, false, err)
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"renewal_job_failed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": job.ID, "error": err.Error()})
return fmt.Errorf("issuer renewal failed: %w", err)
}
// Create new certificate version
version := &domain.CertificateVersion{
ID: generateID("certver"),
CertificateID: job.CertificateID,
SerialNumber: fmt.Sprintf("renewed-%d", time.Now().Unix()),
PEMChain: string(certPEM),
CreatedAt: time.Now(),
}
if err := s.certRepo.CreateVersion(ctx, version); err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("version creation failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to create certificate version: %w", err)
}
// Update certificate status
cert.Status = domain.CertificateStatusActive
if err := s.certRepo.Update(ctx, cert); err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("cert update failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to update certificate: %w", err)
}
// Mark job as completed
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusCompleted, ""); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Send success notification
if err := s.notificationSvc.SendRenewalNotification(ctx, cert, true, nil); err != nil {
fmt.Printf("failed to send renewal notification: %v\n", err)
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"renewal_job_completed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": job.ID, "serial": version.SerialNumber})
return nil
}
// Retry attempts to reprocess failed renewal jobs with exponential backoff.
func (s *RenewalService) RetryFailedJobs(ctx context.Context, maxRetries int) error {
failedJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusFailed)
if err != nil {
return fmt.Errorf("failed to fetch failed jobs: %w", err)
}
for _, job := range failedJobs {
if job.Type != domain.JobTypeRenewal {
continue
}
// Check if we've exceeded max attempts
if job.Attempts >= job.MaxAttempts {
continue
}
// Reset status to pending for retry
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusPending, ""); err != nil {
fmt.Printf("failed to reset job status for retry: %v\n", err)
continue
}
}
return nil
}
// generateID is a helper to generate unique IDs. In production, use a proper ID generator.
func generateID(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}