mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
ae67b10708
- Wire issuer connector end-to-end with IssuerConnectorAdapter (dependency inversion)
- Renewal/issuance job processor: RSA key + CSR generation, Local CA signing, cert version storage
- Agent work API (GET /agents/{id}/work) and job status API (POST /agents/{id}/jobs/{job_id}/status)
- Agent-side deployment: WorkItem enrichment with target type/config, NGINX/F5/IIS connector invocation
- Full ACME v2 implementation: HTTP-01 challenge solving, account registration, order lifecycle
- Update all docs (README, architecture, connectors, demo-advanced, quickstart) for M1-M2
- Fix go vet warning in deployment.go (non-constant format string)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
343 lines
11 KiB
Go
343 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/hex"
|
|
"encoding/pem"
|
|
"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 service-layer interface for interacting with certificate issuers.
|
|
// This is distinct from the connector-layer issuer.Connector interface to maintain dependency
|
|
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
|
type IssuerConnector interface {
|
|
// IssueCertificate issues a new certificate using the provided CSR PEM.
|
|
IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error)
|
|
// RenewCertificate renews a certificate using the provided CSR PEM.
|
|
RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error)
|
|
}
|
|
|
|
// IssuanceResult holds the result of a certificate issuance or renewal operation.
|
|
type IssuanceResult struct {
|
|
CertPEM string
|
|
ChainPEM string
|
|
Serial string
|
|
NotBefore time.Time
|
|
NotAfter time.Time
|
|
}
|
|
|
|
// 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
|
|
|
|
// Send expiration warning notification (always, regardless of issuer availability)
|
|
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)
|
|
}
|
|
|
|
// Only create renewal job if an issuer connector is registered for this cert's issuer
|
|
if _, hasIssuer := s.issuerRegistry[cert.IssuerID]; !hasIssuer {
|
|
continue
|
|
}
|
|
|
|
// Check for existing pending/running renewal jobs to avoid duplicates
|
|
existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID)
|
|
if err == nil {
|
|
hasActiveRenewal := false
|
|
for _, j := range existingJobs {
|
|
if j.Type == domain.JobTypeRenewal &&
|
|
(j.Status == domain.JobStatusPending || j.Status == domain.JobStatusRunning) {
|
|
hasActiveRenewal = true
|
|
break
|
|
}
|
|
}
|
|
if hasActiveRenewal {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Create renewal job
|
|
job := &domain.Job{
|
|
ID: generateID("job"),
|
|
CertificateID: cert.ID,
|
|
Type: domain.JobTypeRenewal,
|
|
Status: domain.JobStatusPending,
|
|
MaxAttempts: 3,
|
|
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
|
|
}
|
|
|
|
// Update certificate status to RenewalInProgress
|
|
cert.Status = domain.CertificateStatusRenewalInProgress
|
|
if err := s.certRepo.Update(ctx, cert); err != nil {
|
|
fmt.Printf("failed to update cert status for %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: generate CSR, call issuer, store new version,
|
|
// update cert status, and create deployment jobs for targets.
|
|
//
|
|
// V1 Architecture Note: For the Local CA issuer, the control plane generates a server-side
|
|
// ephemeral key + CSR. The private key is stored in the CertificateVersion.CSRPEM field
|
|
// so agents can retrieve it for deployment. In V2+ with ACME/external CAs, agents will
|
|
// generate keys locally and submit CSRs, so private keys never leave the target infrastructure.
|
|
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 {
|
|
s.failJob(ctx, job, fmt.Sprintf("certificate fetch failed: %v", err))
|
|
return fmt.Errorf("failed to fetch certificate: %w", err)
|
|
}
|
|
|
|
// Get issuer connector
|
|
issuerID := cert.IssuerID
|
|
if issuerID == "" {
|
|
s.failJob(ctx, job, "certificate has no issuer assigned")
|
|
return fmt.Errorf("certificate has no issuer assigned")
|
|
}
|
|
|
|
connector, ok := s.issuerRegistry[issuerID]
|
|
if !ok {
|
|
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", issuerID))
|
|
return fmt.Errorf("issuer connector not found for %s", issuerID)
|
|
}
|
|
|
|
// Generate server-side RSA key + CSR for this renewal
|
|
// V1: server generates ephemeral key for Local CA. V2+: agent generates key locally.
|
|
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
s.failJob(ctx, job, fmt.Sprintf("key generation failed: %v", err))
|
|
return fmt.Errorf("failed to generate private key: %w", err)
|
|
}
|
|
|
|
csrTemplate := &x509.CertificateRequest{
|
|
Subject: pkix.Name{
|
|
CommonName: cert.CommonName,
|
|
},
|
|
DNSNames: cert.SANs,
|
|
}
|
|
|
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
|
if err != nil {
|
|
s.failJob(ctx, job, fmt.Sprintf("CSR generation failed: %v", err))
|
|
return fmt.Errorf("failed to generate CSR: %w", err)
|
|
}
|
|
|
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE REQUEST",
|
|
Bytes: csrDER,
|
|
}))
|
|
|
|
// Encode private key to PEM for storage (V1: stored so agent can retrieve for deployment)
|
|
privKeyPEM := string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
|
}))
|
|
|
|
// Call issuer connector to renew
|
|
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM)
|
|
if err != nil {
|
|
s.failJob(ctx, job, fmt.Sprintf("issuer renewal failed: %v", err))
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Compute SHA-256 fingerprint of the issued certificate
|
|
fingerprint := computeCertFingerprint(result.CertPEM)
|
|
|
|
// Create new certificate version
|
|
version := &domain.CertificateVersion{
|
|
ID: generateID("certver"),
|
|
CertificateID: job.CertificateID,
|
|
SerialNumber: result.Serial,
|
|
NotBefore: result.NotBefore,
|
|
NotAfter: result.NotAfter,
|
|
FingerprintSHA256: fingerprint,
|
|
PEMChain: result.CertPEM + "\n" + result.ChainPEM,
|
|
CSRPEM: privKeyPEM, // V1: stores private key for agent deployment
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
if err := s.certRepo.CreateVersion(ctx, version); err != nil {
|
|
s.failJob(ctx, job, fmt.Sprintf("version creation failed: %v", err))
|
|
return fmt.Errorf("failed to create certificate version: %w", err)
|
|
}
|
|
|
|
// Update certificate status and expiry
|
|
cert.Status = domain.CertificateStatusActive
|
|
cert.ExpiresAt = result.NotAfter
|
|
now := time.Now()
|
|
cert.LastRenewalAt = &now
|
|
cert.UpdatedAt = now
|
|
if err := s.certRepo.Update(ctx, cert); err != nil {
|
|
s.failJob(ctx, job, fmt.Sprintf("cert update failed: %v", err))
|
|
return fmt.Errorf("failed to update certificate: %w", err)
|
|
}
|
|
|
|
// Mark renewal 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)
|
|
}
|
|
|
|
// Create deployment jobs for each target
|
|
if len(cert.TargetIDs) > 0 {
|
|
for _, targetID := range cert.TargetIDs {
|
|
tid := targetID // capture loop variable
|
|
deployJob := &domain.Job{
|
|
ID: generateID("job"),
|
|
CertificateID: cert.ID,
|
|
Type: domain.JobTypeDeployment,
|
|
Status: domain.JobStatusPending,
|
|
TargetID: &tid,
|
|
MaxAttempts: 3,
|
|
ScheduledAt: time.Now(),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
if err := s.jobRepo.Create(ctx, deployJob); err != nil {
|
|
fmt.Printf("failed to create deployment job for target %s: %v\n", targetID, 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": result.Serial,
|
|
"not_after": result.NotAfter,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// failJob is a helper to mark a job as failed with an error message.
|
|
func (s *RenewalService) failJob(ctx context.Context, job *domain.Job, errMsg string) {
|
|
if updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, errMsg); updateErr != nil {
|
|
fmt.Printf("failed to update job status: %v\n", updateErr)
|
|
}
|
|
}
|
|
|
|
// computeCertFingerprint computes the SHA-256 fingerprint of a PEM-encoded certificate.
|
|
func computeCertFingerprint(certPEM string) string {
|
|
block, _ := pem.Decode([]byte(certPEM))
|
|
if block == nil {
|
|
return ""
|
|
}
|
|
hash := sha256.Sum256(block.Bytes)
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
// RetryFailedJobs resets failed renewal jobs for retry if they haven't exceeded max attempts.
|
|
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())
|
|
}
|