mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 06:49:00 +00:00
Implement M8: agent-side key generation with ECDSA P-256
Private keys never leave agent infrastructure. Agents generate ECDSA P-256 key pairs locally, store them with 0600 permissions, and submit only the CSR (public key) to the control plane. New AwaitingCSR job state pauses renewal/issuance jobs until the agent submits its CSR. Server-side keygen retained behind CERTCTL_KEYGEN_MODE=server for demo/development. Key changes: - Dual keygen mode via CERTCTL_KEYGEN_MODE (agent default, server for demo) - AwaitingCSR job state with CommonName/SANs in work response - Agent ECDSA P-256 keygen, local key storage, CSR-only submission - CompleteAgentCSRRenewal server-side flow for agent-submitted CSRs - DeploymentRequest.KeyPEM for agent-provided keys during deployment - Dockerfile.agent creates /var/lib/certctl/keys with correct ownership Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,15 @@ type Config struct {
|
||||
Auth AuthConfig
|
||||
RateLimit RateLimitConfig
|
||||
CORS CORSConfig
|
||||
Keygen KeygenConfig
|
||||
}
|
||||
|
||||
// KeygenConfig controls where private keys are generated.
|
||||
type KeygenConfig struct {
|
||||
// Mode: "agent" (default, production) or "server" (demo only, Local CA).
|
||||
// In "agent" mode, renewal/issuance jobs enter AwaitingCSR state and agents generate keys locally.
|
||||
// In "server" mode, the control plane generates keys (private keys touch the server — demo only).
|
||||
Mode string
|
||||
}
|
||||
|
||||
// ServerConfig contains HTTP server configuration.
|
||||
@@ -101,6 +110,9 @@ func Load() (*Config, error) {
|
||||
CORS: CORSConfig{
|
||||
AllowedOrigins: getEnvList("CERTCTL_CORS_ORIGINS", nil),
|
||||
},
|
||||
Keygen: KeygenConfig{
|
||||
Mode: getEnv("CERTCTL_KEYGEN_MODE", "agent"),
|
||||
},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
@@ -161,6 +173,15 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("auth secret is required for auth type %s", c.Auth.Type)
|
||||
}
|
||||
|
||||
// Validate keygen mode
|
||||
validKeygenModes := map[string]bool{
|
||||
"agent": true,
|
||||
"server": true,
|
||||
}
|
||||
if !validKeygenModes[c.Keygen.Mode] {
|
||||
return fmt.Errorf("invalid keygen mode: %s (must be 'agent' or 'server')", c.Keygen.Mode)
|
||||
}
|
||||
|
||||
// Validate scheduler intervals
|
||||
if c.Scheduler.RenewalCheckInterval < 1*time.Minute {
|
||||
return fmt.Errorf("renewal check interval must be at least 1 minute")
|
||||
|
||||
@@ -20,9 +20,11 @@ type Connector interface {
|
||||
}
|
||||
|
||||
// DeploymentRequest contains the parameters for deploying a certificate to a target.
|
||||
// Note: This request NEVER contains a private key. The agent generates keys locally.
|
||||
// In agent keygen mode, KeyPEM is populated from the agent's local key store.
|
||||
// In server keygen mode (demo only), KeyPEM may be empty if the key was embedded in the cert version.
|
||||
type DeploymentRequest struct {
|
||||
CertPEM string `json:"cert_pem"`
|
||||
KeyPEM string `json:"key_pem,omitempty"`
|
||||
ChainPEM string `json:"chain_pem"`
|
||||
TargetConfig json.RawMessage `json:"target_config"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
|
||||
@@ -35,11 +35,12 @@ const (
|
||||
type JobStatus string
|
||||
|
||||
const (
|
||||
JobStatusPending JobStatus = "Pending"
|
||||
JobStatusRunning JobStatus = "Running"
|
||||
JobStatusCompleted JobStatus = "Completed"
|
||||
JobStatusFailed JobStatus = "Failed"
|
||||
JobStatusCancelled JobStatus = "Cancelled"
|
||||
JobStatusPending JobStatus = "Pending"
|
||||
JobStatusAwaitingCSR JobStatus = "AwaitingCSR"
|
||||
JobStatusRunning JobStatus = "Running"
|
||||
JobStatusCompleted JobStatus = "Completed"
|
||||
JobStatusFailed JobStatus = "Failed"
|
||||
JobStatusCancelled JobStatus = "Cancelled"
|
||||
)
|
||||
|
||||
// DeploymentJob represents a job that deploys a certificate to a target via an agent.
|
||||
@@ -55,6 +56,8 @@ type WorkItem struct {
|
||||
ID string `json:"id"`
|
||||
Type JobType `json:"type"`
|
||||
CertificateID string `json:"certificate_id"`
|
||||
CommonName string `json:"common_name,omitempty"`
|
||||
SANs []string `json:"sans,omitempty"`
|
||||
TargetID *string `json:"target_id,omitempty"`
|
||||
TargetType string `json:"target_type,omitempty"`
|
||||
TargetConfig json.RawMessage `json:"target_config,omitempty"`
|
||||
|
||||
@@ -52,10 +52,10 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
||||
certificateService := service.NewCertificateService(certRepo, policyService, auditService)
|
||||
notificationService := service.NewNotificationService(notifRepo, make(map[string]service.Notifier))
|
||||
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, auditService, notificationService, issuerRegistry)
|
||||
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, auditService, notificationService, issuerRegistry, "server")
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry)
|
||||
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
||||
|
||||
// Initialize handlers
|
||||
|
||||
+70
-32
@@ -20,6 +20,7 @@ type AgentService struct {
|
||||
targetRepo repository.TargetRepository
|
||||
auditService *AuditService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
renewalService *RenewalService
|
||||
}
|
||||
|
||||
// NewAgentService creates a new agent service.
|
||||
@@ -30,6 +31,7 @@ func NewAgentService(
|
||||
targetRepo repository.TargetRepository,
|
||||
auditService *AuditService,
|
||||
issuerRegistry map[string]IssuerConnector,
|
||||
renewalService *RenewalService,
|
||||
) *AgentService {
|
||||
return &AgentService{
|
||||
agentRepo: agentRepo,
|
||||
@@ -38,6 +40,7 @@ func NewAgentService(
|
||||
targetRepo: targetRepo,
|
||||
auditService: auditService,
|
||||
issuerRegistry: issuerRegistry,
|
||||
renewalService: renewalService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +109,9 @@ func (s *AgentService) Heartbeat(agentID string) error {
|
||||
}
|
||||
|
||||
// SubmitCSR validates and processes a Certificate Signing Request from an agent.
|
||||
// It forwards the CSR to the appropriate issuer connector for signing, then stores
|
||||
// the resulting certificate version.
|
||||
// In agent keygen mode, this completes an AwaitingCSR renewal job by signing the CSR
|
||||
// and storing the cert version. The private key stays on the agent — only the CSR
|
||||
// (public key) reaches the server.
|
||||
func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID string, csrPEM []byte) error {
|
||||
// Fetch agent
|
||||
agent, err := s.agentRepo.Get(ctx, agentID)
|
||||
@@ -120,39 +124,57 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str
|
||||
return fmt.Errorf("invalid CSR: empty")
|
||||
}
|
||||
|
||||
// If a certificate ID is provided, sign the CSR via the issuer connector
|
||||
if certID != "" {
|
||||
cert, err := s.certRepo.Get(ctx, certID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
}
|
||||
|
||||
// Look up the issuer connector
|
||||
// Check for AwaitingCSR jobs first (agent keygen mode)
|
||||
if s.renewalService != nil {
|
||||
awaitingJobs, err := s.renewalService.GetAwaitingCSRJobs(ctx, certID)
|
||||
if err == nil && len(awaitingJobs) > 0 {
|
||||
// Complete the renewal via the renewal service (signs CSR, stores version, creates deploy jobs)
|
||||
if err := s.renewalService.CompleteAgentCSRRenewal(ctx, awaitingJobs[0], cert, string(csrPEM)); err != nil {
|
||||
return fmt.Errorf("failed to complete agent CSR renewal: %w", err)
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
_ = s.auditService.RecordEvent(ctx, agent.ID, domain.ActorTypeAgent,
|
||||
"csr_submitted", "certificate", certID,
|
||||
map[string]interface{}{
|
||||
"agent_hostname": agent.Hostname,
|
||||
"keygen_mode": "agent",
|
||||
"job_id": awaitingJobs[0].ID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: direct issuer signing (no AwaitingCSR job — ad-hoc CSR submission)
|
||||
connector, ok := s.issuerRegistry[cert.IssuerID]
|
||||
if ok {
|
||||
// Sign the CSR via the issuer connector
|
||||
result, err := connector.IssueCertificate(ctx, cert.CommonName, cert.SANs, string(csrPEM))
|
||||
if err != nil {
|
||||
return fmt.Errorf("issuer signing failed: %w", err)
|
||||
}
|
||||
|
||||
// Store the signed certificate as a new version
|
||||
version := &domain.CertificateVersion{
|
||||
ID: generateID("certver"),
|
||||
CertificateID: certID,
|
||||
SerialNumber: result.Serial,
|
||||
NotBefore: result.NotBefore,
|
||||
NotAfter: result.NotAfter,
|
||||
PEMChain: result.CertPEM + "\n" + result.ChainPEM,
|
||||
CSRPEM: string(csrPEM),
|
||||
CreatedAt: time.Now(),
|
||||
ID: generateID("certver"),
|
||||
CertificateID: certID,
|
||||
SerialNumber: result.Serial,
|
||||
NotBefore: result.NotBefore,
|
||||
NotAfter: result.NotAfter,
|
||||
PEMChain: result.CertPEM + "\n" + result.ChainPEM,
|
||||
CSRPEM: string(csrPEM),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.certRepo.CreateVersion(ctx, version); err != nil {
|
||||
return fmt.Errorf("failed to store certificate version: %w", err)
|
||||
}
|
||||
|
||||
// Update certificate status and expiry
|
||||
cert.Status = domain.CertificateStatusActive
|
||||
cert.ExpiresAt = result.NotAfter
|
||||
now := time.Now()
|
||||
@@ -165,11 +187,9 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
if err := s.auditService.RecordEvent(ctx, agent.ID, domain.ActorTypeAgent,
|
||||
_ = s.auditService.RecordEvent(ctx, agent.ID, domain.ActorTypeAgent,
|
||||
"csr_submitted", "certificate", certID,
|
||||
map[string]interface{}{"agent_hostname": agent.Hostname}); err != nil {
|
||||
fmt.Printf("failed to record audit event: %v\n", err)
|
||||
}
|
||||
map[string]interface{}{"agent_hostname": agent.Hostname})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -210,7 +230,8 @@ func (s *AgentService) GetCertificateForAgent(ctx context.Context, agentID strin
|
||||
return []byte(latestVersion.PEMChain), nil
|
||||
}
|
||||
|
||||
// GetPendingWork returns deployment jobs assigned to an agent.
|
||||
// GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and
|
||||
// renewal/issuance jobs awaiting CSR submission (AwaitingCSR).
|
||||
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||
// Fetch agent to verify it exists
|
||||
_, err := s.agentRepo.Get(ctx, agentID)
|
||||
@@ -218,23 +239,30 @@ func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*d
|
||||
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
||||
}
|
||||
|
||||
// Get all deployment jobs
|
||||
jobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
|
||||
var workForAgent []*domain.Job
|
||||
|
||||
// Get pending deployment jobs
|
||||
pendingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list pending jobs: %w", err)
|
||||
}
|
||||
|
||||
var workForAgent []*domain.Job
|
||||
|
||||
// Filter to only jobs assigned to this agent
|
||||
// Note: In this implementation, agents don't filter jobs by assignment
|
||||
// All deployment jobs are returned for the agent to process
|
||||
for _, job := range jobs {
|
||||
for _, job := range pendingJobs {
|
||||
if job.Type == domain.JobTypeDeployment {
|
||||
workForAgent = append(workForAgent, job)
|
||||
}
|
||||
}
|
||||
|
||||
// Get AwaitingCSR jobs (agent keygen mode — agent needs to generate key + submit CSR)
|
||||
awaitingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusAwaitingCSR)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list awaiting CSR jobs: %w", err)
|
||||
}
|
||||
for _, job := range awaitingJobs {
|
||||
if job.Type == domain.JobTypeRenewal || job.Type == domain.JobTypeIssuance {
|
||||
workForAgent = append(workForAgent, job)
|
||||
}
|
||||
}
|
||||
|
||||
return workForAgent, nil
|
||||
}
|
||||
|
||||
@@ -381,8 +409,9 @@ func (s *AgentService) GetWork(agentID string) ([]domain.Job, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetWorkWithTargets returns pending deployment jobs enriched with target type and config.
|
||||
// This allows agents to know which connector to invoke for each deployment job.
|
||||
// GetWorkWithTargets returns actionable jobs enriched with target/certificate details.
|
||||
// Deployment jobs include target type + config. AwaitingCSR jobs include common name + SANs
|
||||
// so the agent knows what CSR to generate.
|
||||
func (s *AgentService) GetWorkWithTargets(agentID string) ([]domain.WorkItem, error) {
|
||||
jobs, err := s.GetPendingWork(context.Background(), agentID)
|
||||
if err != nil {
|
||||
@@ -402,7 +431,7 @@ func (s *AgentService) GetWorkWithTargets(agentID string) ([]domain.WorkItem, er
|
||||
Status: j.Status,
|
||||
}
|
||||
|
||||
// Enrich with target details if target ID is present
|
||||
// Enrich with target details for deployment jobs
|
||||
if j.TargetID != nil && *j.TargetID != "" {
|
||||
target, err := s.targetRepo.Get(context.Background(), *j.TargetID)
|
||||
if err == nil {
|
||||
@@ -411,6 +440,15 @@ func (s *AgentService) GetWorkWithTargets(agentID string) ([]domain.WorkItem, er
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with certificate details for AwaitingCSR jobs (agent needs CN + SANs for CSR)
|
||||
if j.Status == domain.JobStatusAwaitingCSR {
|
||||
cert, err := s.certRepo.Get(context.Background(), j.CertificateID)
|
||||
if err == nil {
|
||||
item.CommonName = cert.CommonName
|
||||
item.SANs = cert.SANs
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func newTestJobService(jobRepo *mockJobRepo) *JobService {
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
||||
|
||||
renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, auditService, notifService, make(map[string]IssuerConnector))
|
||||
renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, auditService, notifService, make(map[string]IssuerConnector), "server")
|
||||
deploymentService := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notifService)
|
||||
|
||||
return NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
|
||||
+179
-37
@@ -24,6 +24,7 @@ type RenewalService struct {
|
||||
auditService *AuditService
|
||||
notificationSvc *NotificationService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
keygenMode string // "agent" (default) or "server" (demo only)
|
||||
}
|
||||
|
||||
// IssuerConnector defines the service-layer interface for interacting with certificate issuers.
|
||||
@@ -53,7 +54,11 @@ func NewRenewalService(
|
||||
auditService *AuditService,
|
||||
notificationSvc *NotificationService,
|
||||
issuerRegistry map[string]IssuerConnector,
|
||||
keygenMode string,
|
||||
) *RenewalService {
|
||||
if keygenMode == "" {
|
||||
keygenMode = "agent"
|
||||
}
|
||||
return &RenewalService{
|
||||
certRepo: certRepo,
|
||||
jobRepo: jobRepo,
|
||||
@@ -61,6 +66,7 @@ func NewRenewalService(
|
||||
auditService: auditService,
|
||||
notificationSvc: notificationSvc,
|
||||
issuerRegistry: issuerRegistry,
|
||||
keygenMode: keygenMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,13 +238,13 @@ func (s *RenewalService) updateCertExpiryStatus(ctx context.Context, cert *domai
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessRenewalJob executes a renewal job: generate CSR, call issuer, store new version,
|
||||
// update cert status, and create deployment jobs for targets.
|
||||
// ProcessRenewalJob executes a renewal job. Behavior depends on keygen mode:
|
||||
//
|
||||
// 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.
|
||||
// Agent mode (default, production): Sets job to AwaitingCSR. The agent generates keys
|
||||
// locally, submits a CSR, and the server signs it. Private keys never leave the agent.
|
||||
//
|
||||
// Server mode (demo only, Local CA): Server generates RSA key + CSR, signs via issuer,
|
||||
// stores cert version with private key so agent can retrieve it for deployment.
|
||||
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 {
|
||||
@@ -259,14 +265,50 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
||||
return fmt.Errorf("certificate has no issuer assigned")
|
||||
}
|
||||
|
||||
connector, ok := s.issuerRegistry[issuerID]
|
||||
_, 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.
|
||||
// Branch on keygen mode
|
||||
if s.keygenMode == "agent" {
|
||||
return s.processRenewalAgentKeygen(ctx, job, cert)
|
||||
}
|
||||
return s.processRenewalServerKeygen(ctx, job, cert)
|
||||
}
|
||||
|
||||
// processRenewalAgentKeygen sets the job to AwaitingCSR so an agent can generate keys
|
||||
// locally and submit a CSR. The server never touches the private key.
|
||||
func (s *RenewalService) processRenewalAgentKeygen(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate) error {
|
||||
// Transition job to AwaitingCSR — agent will pick this up during work polling
|
||||
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusAwaitingCSR, ""); err != nil {
|
||||
return fmt.Errorf("failed to set job to AwaitingCSR: %w", err)
|
||||
}
|
||||
|
||||
// Update certificate status
|
||||
cert.Status = domain.CertificateStatusRenewalInProgress
|
||||
cert.UpdatedAt = time.Now()
|
||||
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_awaiting_csr", "certificate", job.CertificateID,
|
||||
map[string]interface{}{"job_id": job.ID, "keygen_mode": "agent"})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processRenewalServerKeygen is the legacy server-side keygen flow for Local CA demo.
|
||||
// The server generates an ephemeral RSA key + CSR, signs via issuer, and stores the
|
||||
// private key in the cert version so agents can retrieve it for deployment.
|
||||
// WARNING: Private keys touch the control plane. Use only for development/demo.
|
||||
func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate) error {
|
||||
connector := s.issuerRegistry[cert.IssuerID]
|
||||
|
||||
// Generate server-side RSA key + CSR
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
s.failJob(ctx, job, fmt.Sprintf("key generation failed: %v", err))
|
||||
@@ -291,7 +333,7 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
||||
Bytes: csrDER,
|
||||
}))
|
||||
|
||||
// Encode private key to PEM for storage (V1: stored so agent can retrieve for deployment)
|
||||
// Encode private key to PEM for storage (server mode: stored so agent can retrieve)
|
||||
privKeyPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||
@@ -301,15 +343,10 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -325,7 +362,7 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
||||
NotAfter: result.NotAfter,
|
||||
FingerprintSHA256: fingerprint,
|
||||
PEMChain: result.CertPEM + "\n" + result.ChainPEM,
|
||||
CSRPEM: privKeyPEM, // V1: stores private key for agent deployment
|
||||
CSRPEM: privKeyPEM, // Server mode: stores private key for agent deployment
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -351,24 +388,7 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.createDeploymentJobs(ctx, cert)
|
||||
|
||||
// Send success notification
|
||||
if err := s.notificationSvc.SendRenewalNotification(ctx, cert, true, nil); err != nil {
|
||||
@@ -379,14 +399,136 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
||||
_ = 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,
|
||||
"job_id": job.ID,
|
||||
"serial": result.Serial,
|
||||
"not_after": result.NotAfter,
|
||||
"keygen_mode": "server",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteAgentCSRRenewal is called when an agent submits a CSR for an AwaitingCSR job.
|
||||
// It signs the CSR via the issuer connector, stores the cert version (without private key),
|
||||
// completes the renewal job, and creates deployment jobs.
|
||||
func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate, csrPEM string) error {
|
||||
connector, ok := s.issuerRegistry[cert.IssuerID]
|
||||
if !ok {
|
||||
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", cert.IssuerID))
|
||||
return fmt.Errorf("issuer connector not found for %s", cert.IssuerID)
|
||||
}
|
||||
|
||||
// Update job to running
|
||||
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusRunning, ""); err != nil {
|
||||
return fmt.Errorf("failed to update job status: %w", err)
|
||||
}
|
||||
|
||||
// Sign the agent-submitted CSR via issuer
|
||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM)
|
||||
if err != nil {
|
||||
s.failJob(ctx, job, fmt.Sprintf("issuer signing failed: %v", err))
|
||||
_ = s.notificationSvc.SendRenewalNotification(ctx, cert, false, err)
|
||||
_ = 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 signing failed: %w", err)
|
||||
}
|
||||
|
||||
fingerprint := computeCertFingerprint(result.CertPEM)
|
||||
|
||||
// Store cert version — CSRPEM holds the actual CSR (not the private key!)
|
||||
version := &domain.CertificateVersion{
|
||||
ID: generateID("certver"),
|
||||
CertificateID: cert.ID,
|
||||
SerialNumber: result.Serial,
|
||||
NotBefore: result.NotBefore,
|
||||
NotAfter: result.NotAfter,
|
||||
FingerprintSHA256: fingerprint,
|
||||
PEMChain: result.CertPEM + "\n" + result.ChainPEM,
|
||||
CSRPEM: csrPEM, // Agent mode: stores actual CSR, not private key
|
||||
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 job 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
|
||||
s.createDeploymentJobs(ctx, cert)
|
||||
|
||||
// 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", cert.ID,
|
||||
map[string]interface{}{
|
||||
"job_id": job.ID,
|
||||
"serial": result.Serial,
|
||||
"not_after": result.NotAfter,
|
||||
"keygen_mode": "agent",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDeploymentJobs creates pending deployment jobs for each target associated with a cert.
|
||||
func (s *RenewalService) createDeploymentJobs(ctx context.Context, cert *domain.ManagedCertificate) {
|
||||
if len(cert.TargetIDs) == 0 {
|
||||
return
|
||||
}
|
||||
for _, targetID := range cert.TargetIDs {
|
||||
tid := targetID
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAwaitingCSRJobs returns all jobs in AwaitingCSR state for a given certificate.
|
||||
func (s *RenewalService) GetAwaitingCSRJobs(ctx context.Context, certID string) ([]*domain.Job, error) {
|
||||
jobs, err := s.jobRepo.ListByCertificate(ctx, certID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var awaiting []*domain.Job
|
||||
for _, j := range jobs {
|
||||
if j.Status == domain.JobStatusAwaitingCSR {
|
||||
awaiting = append(awaiting, j)
|
||||
}
|
||||
}
|
||||
return awaiting, 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 {
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestCheckExpiringCertificates_SendsThresholdAlerts(t *testing.T) {
|
||||
"iss-test": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create a cert expiring in 10 days
|
||||
cert := &domain.ManagedCertificate{
|
||||
@@ -112,7 +112,7 @@ func TestCheckExpiringCertificates_DeduplicatesAlerts(t *testing.T) {
|
||||
"iss-test": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create cert
|
||||
cert := &domain.ManagedCertificate{
|
||||
@@ -192,7 +192,7 @@ func TestCheckExpiringCertificates_SkipsRenewalInProgress(t *testing.T) {
|
||||
"iss-test": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create cert with RenewalInProgress status
|
||||
cert := &domain.ManagedCertificate{
|
||||
@@ -257,7 +257,7 @@ func TestCheckExpiringCertificates_UpdatesStatusToExpiring(t *testing.T) {
|
||||
"iss-test": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create active cert that will become expiring
|
||||
// Use an issuer NOT in the registry so no renewal job is created (which would override status)
|
||||
@@ -319,7 +319,7 @@ func TestCheckExpiringCertificates_UpdatesStatusToExpired(t *testing.T) {
|
||||
"iss-test": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create cert that is already expired
|
||||
// Use an issuer NOT in the registry so no renewal job is created (which would override status)
|
||||
@@ -381,7 +381,7 @@ func TestCheckExpiringCertificates_CreatesRenewalJob(t *testing.T) {
|
||||
"iss-test": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create expiring cert with registered issuer
|
||||
cert := &domain.ManagedCertificate{
|
||||
@@ -447,7 +447,7 @@ func TestCheckExpiringCertificates_SkipsWithoutIssuer(t *testing.T) {
|
||||
// Empty issuer registry
|
||||
issuerRegistry := map[string]IssuerConnector{}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create cert with unregistered issuer
|
||||
cert := &domain.ManagedCertificate{
|
||||
@@ -509,7 +509,7 @@ func TestCheckExpiringCertificates_SkipsDuplicateJobs(t *testing.T) {
|
||||
"iss-test": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create cert
|
||||
cert := &domain.ManagedCertificate{
|
||||
@@ -593,7 +593,7 @@ func TestProcessRenewalJob(t *testing.T) {
|
||||
"iss-test": issuerConnector,
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create certificate
|
||||
cert := &domain.ManagedCertificate{
|
||||
@@ -689,7 +689,7 @@ func TestProcessRenewalJob_IssuerFailure(t *testing.T) {
|
||||
"iss-test": issuerConnector,
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create certificate
|
||||
cert := &domain.ManagedCertificate{
|
||||
@@ -771,7 +771,7 @@ func TestRetryFailedJobs(t *testing.T) {
|
||||
"iss-test": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create failed job with attempts < max_attempts
|
||||
failedJob := &domain.Job{
|
||||
@@ -836,7 +836,7 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) {
|
||||
"iss-test": &mockIssuerConnector{},
|
||||
}
|
||||
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry)
|
||||
svc := NewRenewalService(certRepo, jobRepo, policyRepo, auditSvc, notifSvc, issuerRegistry, "server")
|
||||
|
||||
// Create job with non-existent certificate
|
||||
job := &domain.Job{
|
||||
|
||||
Reference in New Issue
Block a user