mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 13:38:52 +00:00
226 lines
7.4 KiB
Go
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())
|
|
}
|