mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:22:07 +00:00
Complete M1, M1.1, M2: end-to-end lifecycle, agent deployment, ACME v2
- 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>
This commit is contained in:
@@ -17,7 +17,11 @@ type AgentService interface {
|
||||
RegisterAgent(agent domain.Agent) (*domain.Agent, error)
|
||||
Heartbeat(agentID string) error
|
||||
CSRSubmit(agentID string, csrPEM string) (string, error)
|
||||
CSRSubmitForCert(agentID string, certID string, csrPEM string) (string, error)
|
||||
CertificatePickup(agentID, certID string) (string, error)
|
||||
GetWork(agentID string) ([]domain.Job, error)
|
||||
GetWorkWithTargets(agentID string) ([]domain.WorkItem, error)
|
||||
UpdateJobStatus(agentID string, jobID string, status string, errMsg string) error
|
||||
}
|
||||
|
||||
// AgentHandler handles HTTP requests for agent operations.
|
||||
@@ -155,6 +159,7 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// AgentCSRSubmit receives a Certificate Signing Request from an agent.
|
||||
// POST /api/v1/agents/{id}/csr
|
||||
// Optionally accepts a certificate_id to sign the CSR for a specific certificate.
|
||||
func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
@@ -173,7 +178,8 @@ func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := parts[0]
|
||||
|
||||
var req struct {
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
CertificateID string `json:"certificate_id,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
@@ -185,15 +191,23 @@ func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
jobID, err := h.svc.CSRSubmit(agentID, req.CSRPEM)
|
||||
var status string
|
||||
var err error
|
||||
|
||||
// If certificate_id is provided, sign the CSR for that specific certificate
|
||||
if req.CertificateID != "" {
|
||||
status, err = h.svc.CSRSubmitForCert(agentID, req.CertificateID, req.CSRPEM)
|
||||
} else {
|
||||
status, err = h.svc.CSRSubmit(agentID, req.CSRPEM)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to submit CSR", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]string{
|
||||
"job_id": jobID,
|
||||
"status": "csr_received",
|
||||
"status": status,
|
||||
}
|
||||
|
||||
JSON(w, http.StatusAccepted, response)
|
||||
@@ -231,3 +245,82 @@ func (h AgentHandler) AgentCertificatePickup(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
JSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// AgentGetWork returns pending deployment jobs for an agent.
|
||||
// GET /api/v1/agents/{id}/work
|
||||
func (h AgentHandler) AgentGetWork(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract agent ID from path /api/v1/agents/{id}/work
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[0] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID is required", requestID)
|
||||
return
|
||||
}
|
||||
agentID := parts[0]
|
||||
|
||||
workItems, err := h.svc.GetWorkWithTargets(agentID)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get pending work", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
if workItems == nil {
|
||||
workItems = []domain.WorkItem{}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"jobs": workItems,
|
||||
"count": len(workItems),
|
||||
})
|
||||
}
|
||||
|
||||
// AgentReportJobStatus receives a job status report from an agent.
|
||||
// POST /api/v1/agents/{id}/jobs/{job_id}/status
|
||||
func (h AgentHandler) AgentReportJobStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract agent ID and job ID from path /api/v1/agents/{id}/jobs/{job_id}/status
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 4 || parts[0] == "" || parts[2] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID and Job ID are required", requestID)
|
||||
return
|
||||
}
|
||||
agentID := parts[0]
|
||||
jobID := parts[2]
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Status is required", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UpdateJobStatus(agentID, jobID, req.Status, req.Error); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update job status", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{
|
||||
"status": "updated",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ func (r *Router) RegisterHandlers(
|
||||
r.Register("POST /api/v1/agents/{id}/heartbeat", http.HandlerFunc(agents.Heartbeat))
|
||||
r.Register("POST /api/v1/agents/{id}/csr", http.HandlerFunc(agents.AgentCSRSubmit))
|
||||
r.Register("GET /api/v1/agents/{id}/certificates/{cert_id}", http.HandlerFunc(agents.AgentCertificatePickup))
|
||||
r.Register("GET /api/v1/agents/{id}/work", http.HandlerFunc(agents.AgentGetWork))
|
||||
r.Register("POST /api/v1/agents/{id}/jobs/{job_id}/status", http.HandlerFunc(agents.AgentReportJobStatus))
|
||||
|
||||
// Jobs routes: /api/v1/jobs
|
||||
r.Register("GET /api/v1/jobs", http.HandlerFunc(jobs.ListJobs))
|
||||
|
||||
@@ -2,43 +2,65 @@ package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Config represents the ACME issuer connector configuration.
|
||||
type Config struct {
|
||||
DirectoryURL string `json:"directory_url"`
|
||||
Email string `json:"email"`
|
||||
EABKid string `json:"eab_kid,omitempty"`
|
||||
EABHmac string `json:"eab_hmac,omitempty"`
|
||||
DirectoryURL string `json:"directory_url"` // ACME directory URL (e.g., https://acme-staging-v02.api.letsencrypt.org/directory)
|
||||
Email string `json:"email"` // Contact email for the ACME account
|
||||
EABKid string `json:"eab_kid,omitempty"` // External Account Binding Key ID (for some CAs)
|
||||
EABHmac string `json:"eab_hmac,omitempty"` // External Account Binding HMAC Key
|
||||
HTTPPort int `json:"http_port,omitempty"` // Port for HTTP-01 challenge server (default: 80)
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for ACME-compatible CAs.
|
||||
// This is a stub implementation that demonstrates the structure; actual ACME protocol
|
||||
// implementation will use a proper ACME library (e.g., golang.org/x/crypto/acme).
|
||||
// Connector implements the issuer.Connector interface for ACME-compatible CAs
|
||||
// (Let's Encrypt, Sectigo, ZeroSSL, etc.).
|
||||
//
|
||||
// It supports HTTP-01 challenge solving via a built-in temporary HTTP server.
|
||||
// The challenge server starts when needed and stops after validation completes.
|
||||
//
|
||||
// For HTTP-01 to work, the domain(s) being validated must resolve to the machine
|
||||
// running this connector, and the configured HTTP port must be reachable from the internet.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
client *acme.Client
|
||||
accountKey *ecdsa.PrivateKey
|
||||
|
||||
// HTTP-01 challenge solver state
|
||||
challengeMu sync.RWMutex
|
||||
challengeTokens map[string]string // token → key authorization
|
||||
}
|
||||
|
||||
// New creates a new ACME connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
if config != nil && config.HTTPPort == 0 {
|
||||
config.HTTPPort = 80
|
||||
}
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
config: config,
|
||||
logger: logger,
|
||||
challengeTokens: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the ACME directory URL is reachable and valid.
|
||||
// It performs a HEAD request to the directory URL to verify connectivity.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
@@ -56,12 +78,13 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
c.logger.Info("validating ACME configuration", "directory_url", cfg.DirectoryURL)
|
||||
|
||||
// Verify that the directory URL is reachable
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, cfg.DirectoryURL, nil)
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.DirectoryURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reach ACME directory: %w", err)
|
||||
}
|
||||
@@ -71,116 +94,365 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
return fmt.Errorf("ACME directory returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if cfg.HTTPPort == 0 {
|
||||
cfg.HTTPPort = 80
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("ACME configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureClient initializes the ACME client and account key if not already done.
|
||||
func (c *Connector) ensureClient(ctx context.Context) error {
|
||||
if c.client != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate an ECDSA P-256 account key
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate account key: %w", err)
|
||||
}
|
||||
c.accountKey = key
|
||||
|
||||
c.client = &acme.Client{
|
||||
Key: key,
|
||||
DirectoryURL: c.config.DirectoryURL,
|
||||
}
|
||||
|
||||
// Register or retrieve the ACME account
|
||||
acct := &acme.Account{
|
||||
Contact: []string{"mailto:" + c.config.Email},
|
||||
}
|
||||
_, err = c.client.Register(ctx, acct, acme.AcceptTOS)
|
||||
if err != nil {
|
||||
// Account may already exist, try to get it
|
||||
_, getErr := c.client.GetReg(ctx, "")
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("failed to register ACME account: %w (get existing: %v)", err, getErr)
|
||||
}
|
||||
c.logger.Info("using existing ACME account")
|
||||
} else {
|
||||
c.logger.Info("registered new ACME account", "email", c.config.Email)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueCertificate submits a certificate issuance request to the ACME CA.
|
||||
//
|
||||
// The flow for ACME is:
|
||||
// 1. Create a new order with the CA, specifying the identifiers (SANs + CN)
|
||||
// 2. The CA returns authorization challenges (DNS, HTTP, etc.)
|
||||
// 3. Solve the challenges (stub: in production, the agent or external solver handles this)
|
||||
// 4. Finalize the order by submitting the CSR
|
||||
// 5. Download the issued certificate and chain
|
||||
//
|
||||
// TODO: Implement actual ACME protocol using golang.org/x/crypto/acme.
|
||||
// This stub documents the expected flow but doesn't execute it.
|
||||
// Flow:
|
||||
// 1. Create a new order with the CA for the requested identifiers
|
||||
// 2. Solve HTTP-01 challenges for each authorization
|
||||
// 3. Finalize the order by submitting the CSR
|
||||
// 4. Download the issued certificate and chain
|
||||
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing ACME issuance request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
// TODO: Implement ACME order creation.
|
||||
// For now, return a stub response to demonstrate the interface.
|
||||
// In production:
|
||||
// 1. Connect to the ACME directory
|
||||
// 2. Create a new order with identifiers from CommonName and SANs
|
||||
// 3. Get authorization challenges
|
||||
// 4. Wait for challenge completion (agent/solver will handle)
|
||||
// 5. Submit CSR to finalize order
|
||||
// 6. Retrieve issued certificate and chain
|
||||
if err := c.ensureClient(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ACME client init: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Warn("ACME issuance not yet implemented", "common_name", request.CommonName)
|
||||
// Build the list of identifiers (domains)
|
||||
identifiers := buildIdentifiers(request.CommonName, request.SANs)
|
||||
|
||||
// Step 1: Create order
|
||||
order, err := c.client.AuthorizeOrder(ctx, identifiers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create ACME order: %w", err)
|
||||
}
|
||||
c.logger.Info("ACME order created", "order_url", order.URI, "status", order.Status)
|
||||
|
||||
// Step 2: Solve authorizations (HTTP-01 challenges)
|
||||
if order.Status == acme.StatusPending {
|
||||
if err := c.solveAuthorizations(ctx, order.AuthzURLs); err != nil {
|
||||
return nil, fmt.Errorf("failed to solve challenges: %w", err)
|
||||
}
|
||||
|
||||
// Wait for the order to be ready
|
||||
order, err = c.client.WaitOrder(ctx, order.URI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("order failed after challenge: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if order.Status != acme.StatusReady {
|
||||
return nil, fmt.Errorf("order not ready, status: %s", order.Status)
|
||||
}
|
||||
|
||||
// Step 3: Parse CSR and finalize order
|
||||
csrDER, err := parseCSRPEM(request.CSRPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse CSR: %w", err)
|
||||
}
|
||||
|
||||
derChain, _, err := c.client.CreateOrderCert(ctx, order.FinalizeURL, csrDER, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to finalize order: %w", err)
|
||||
}
|
||||
|
||||
if len(derChain) == 0 {
|
||||
return nil, fmt.Errorf("ACME returned empty certificate chain")
|
||||
}
|
||||
|
||||
// Step 4: Convert DER chain to PEM
|
||||
certPEM, chainPEM, serial, notBefore, notAfter, err := parseDERChain(derChain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate chain: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("ACME certificate issued",
|
||||
"common_name", request.CommonName,
|
||||
"serial", serial,
|
||||
"not_after", notAfter)
|
||||
|
||||
// Stub: Return a placeholder result
|
||||
return &issuer.IssuanceResult{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\n(stub)\n-----END CERTIFICATE-----\n",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\n(stub chain)\n-----END CERTIFICATE-----\n",
|
||||
Serial: "stub-serial-123456",
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(0, 0, 90),
|
||||
OrderID: "stub-order-id",
|
||||
CertPEM: certPEM,
|
||||
ChainPEM: chainPEM,
|
||||
Serial: serial,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
OrderID: order.URI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewCertificate renews an existing certificate by submitting a new ACME order.
|
||||
// The process is identical to IssueCertificate but uses the existing CSR from the previous certificate.
|
||||
//
|
||||
// TODO: Implement actual ACME protocol using golang.org/x/crypto/acme.
|
||||
// RenewCertificate renews a certificate by creating a new ACME order.
|
||||
// The process is identical to issuance — ACME doesn't distinguish between new and renewal.
|
||||
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing ACME renewal request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
// TODO: Implement ACME renewal.
|
||||
// In production:
|
||||
// 1. Create a new order with the same identifiers
|
||||
// 2. Obtain and solve authorization challenges
|
||||
// 3. Submit the CSR (from request.CSRPEM)
|
||||
// 4. Retrieve the issued certificate and chain
|
||||
|
||||
c.logger.Warn("ACME renewal not yet implemented", "common_name", request.CommonName)
|
||||
|
||||
// Stub: Return a placeholder result
|
||||
return &issuer.IssuanceResult{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\n(stub renewed)\n-----END CERTIFICATE-----\n",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\n(stub chain)\n-----END CERTIFICATE-----\n",
|
||||
Serial: "stub-serial-renewal-123456",
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(0, 0, 90),
|
||||
OrderID: "stub-order-renewal-id",
|
||||
}, nil
|
||||
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: request.CommonName,
|
||||
SANs: request.SANs,
|
||||
CSRPEM: request.CSRPEM,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate at the ACME CA.
|
||||
// The CA will no longer consider the certificate valid.
|
||||
//
|
||||
// TODO: Implement revocation via ACME protocol.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
c.logger.Info("processing ACME revocation request", "serial", request.Serial)
|
||||
|
||||
// TODO: Implement ACME revocation.
|
||||
// In production:
|
||||
// 1. Retrieve the certificate PEM
|
||||
// 2. Post revocation request to CA's revocation endpoint
|
||||
// 3. Provide reason if given
|
||||
if err := c.ensureClient(ctx); err != nil {
|
||||
return fmt.Errorf("ACME client init: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Warn("ACME revocation not yet implemented", "serial", request.Serial)
|
||||
return nil
|
||||
// ACME revocation requires the certificate DER, not just the serial.
|
||||
// For now, log a warning. Full revocation requires storing the cert DER
|
||||
// or re-fetching it from the order.
|
||||
c.logger.Warn("ACME revocation requires certificate DER bytes; serial-only revocation not supported in V1",
|
||||
"serial", request.Serial)
|
||||
return fmt.Errorf("ACME revocation by serial not supported in V1; provide certificate DER")
|
||||
}
|
||||
|
||||
// GetOrderStatus retrieves the current status of an ACME order.
|
||||
// This is useful for polling the status of pending issuance or renewal orders.
|
||||
//
|
||||
// TODO: Implement order status polling.
|
||||
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||
c.logger.Info("fetching ACME order status", "order_id", orderID)
|
||||
|
||||
// TODO: Implement ACME order status polling.
|
||||
// In production:
|
||||
// 1. Connect to the ACME directory
|
||||
// 2. Fetch order status by orderID
|
||||
// 3. Return current status, message, and any issued certificate material
|
||||
if err := c.ensureClient(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ACME client init: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Warn("ACME order status polling not yet implemented", "order_id", orderID)
|
||||
order, err := c.client.GetOrder(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get order: %w", err)
|
||||
}
|
||||
|
||||
// Stub: Return a placeholder status
|
||||
return &issuer.OrderStatus{
|
||||
status := &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "processing",
|
||||
Message: nil,
|
||||
Status: string(order.Status),
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// solveAuthorizations processes all authorization URLs and solves their HTTP-01 challenges.
|
||||
func (c *Connector) solveAuthorizations(ctx context.Context, authzURLs []string) error {
|
||||
// Start the challenge server
|
||||
srv, err := c.startChallengeServer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start challenge server: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
c.logger.Debug("challenge server stopped")
|
||||
}()
|
||||
|
||||
for _, authzURL := range authzURLs {
|
||||
authz, err := c.client.GetAuthorization(ctx, authzURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get authorization %s: %w", authzURL, err)
|
||||
}
|
||||
|
||||
if authz.Status == acme.StatusValid {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the HTTP-01 challenge
|
||||
var httpChallenge *acme.Challenge
|
||||
for _, ch := range authz.Challenges {
|
||||
if ch.Type == "http-01" {
|
||||
httpChallenge = ch
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if httpChallenge == nil {
|
||||
return fmt.Errorf("no HTTP-01 challenge found for %s", authz.Identifier.Value)
|
||||
}
|
||||
|
||||
// Compute the key authorization
|
||||
keyAuth, err := c.client.HTTP01ChallengeResponse(httpChallenge.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compute key authorization: %w", err)
|
||||
}
|
||||
|
||||
// Store it for the challenge server to serve
|
||||
c.challengeMu.Lock()
|
||||
c.challengeTokens[httpChallenge.Token] = keyAuth
|
||||
c.challengeMu.Unlock()
|
||||
|
||||
c.logger.Info("accepting HTTP-01 challenge",
|
||||
"domain", authz.Identifier.Value,
|
||||
"token", httpChallenge.Token)
|
||||
|
||||
// Tell the CA we're ready
|
||||
if _, err := c.client.Accept(ctx, httpChallenge); err != nil {
|
||||
return fmt.Errorf("failed to accept challenge: %w", err)
|
||||
}
|
||||
|
||||
// Wait for authorization to be valid
|
||||
if _, err := c.client.WaitAuthorization(ctx, authzURL); err != nil {
|
||||
return fmt.Errorf("authorization failed for %s: %w", authz.Identifier.Value, err)
|
||||
}
|
||||
|
||||
c.logger.Info("authorization validated", "domain", authz.Identifier.Value)
|
||||
|
||||
// Clean up token
|
||||
c.challengeMu.Lock()
|
||||
delete(c.challengeTokens, httpChallenge.Token)
|
||||
c.challengeMu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startChallengeServer starts an HTTP server that responds to ACME HTTP-01 challenges.
|
||||
// It listens on the configured HTTP port and serves challenge tokens at
|
||||
// /.well-known/acme-challenge/{token}.
|
||||
func (c *Connector) startChallengeServer() (*http.Server, error) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Path[len("/.well-known/acme-challenge/"):]
|
||||
|
||||
c.challengeMu.RLock()
|
||||
keyAuth, ok := c.challengeTokens[token]
|
||||
c.challengeMu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
c.logger.Warn("unknown challenge token", "token", token)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
c.logger.Debug("serving challenge response", "token", token)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write([]byte(keyAuth))
|
||||
})
|
||||
|
||||
addr := fmt.Sprintf(":%d", c.config.HTTPPort)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
c.logger.Info("challenge server started", "address", addr)
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
c.logger.Error("challenge server error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// buildIdentifiers constructs ACME domain identifiers from common name and SANs.
|
||||
func buildIdentifiers(commonName string, sans []string) []acme.AuthzID {
|
||||
seen := make(map[string]bool)
|
||||
var ids []acme.AuthzID
|
||||
|
||||
// Add CN first
|
||||
if commonName != "" {
|
||||
seen[commonName] = true
|
||||
ids = append(ids, acme.AuthzID{Type: "dns", Value: commonName})
|
||||
}
|
||||
|
||||
// Add SANs, deduplicating
|
||||
for _, san := range sans {
|
||||
if san != "" && !seen[san] {
|
||||
seen[san] = true
|
||||
ids = append(ids, acme.AuthzID{Type: "dns", Value: san})
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// parseCSRPEM decodes a PEM-encoded CSR to DER bytes.
|
||||
func parseCSRPEM(csrPEM string) ([]byte, error) {
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode CSR PEM")
|
||||
}
|
||||
if block.Type != "CERTIFICATE REQUEST" {
|
||||
return nil, fmt.Errorf("unexpected PEM type: %s (expected CERTIFICATE REQUEST)", block.Type)
|
||||
}
|
||||
return block.Bytes, nil
|
||||
}
|
||||
|
||||
// parseDERChain converts a DER certificate chain to PEM strings and extracts metadata.
|
||||
func parseDERChain(derChain [][]byte) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||
if len(derChain) == 0 {
|
||||
err = fmt.Errorf("empty certificate chain")
|
||||
return
|
||||
}
|
||||
|
||||
// First cert is the leaf
|
||||
leafCert, parseErr := x509.ParseCertificate(derChain[0])
|
||||
if parseErr != nil {
|
||||
err = fmt.Errorf("failed to parse leaf certificate: %w", parseErr)
|
||||
return
|
||||
}
|
||||
|
||||
serial = leafCert.SerialNumber.String()
|
||||
notBefore = leafCert.NotBefore
|
||||
notAfter = leafCert.NotAfter
|
||||
|
||||
// Encode leaf to PEM
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: derChain[0],
|
||||
}))
|
||||
|
||||
// Encode remaining chain certs to PEM
|
||||
for i := 1; i < len(derChain); i++ {
|
||||
chainPEM += string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: derChain[i],
|
||||
}))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -48,3 +48,15 @@ type DeploymentJob struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
DeploymentResult json.RawMessage `json:"deployment_result,omitempty"`
|
||||
}
|
||||
|
||||
// WorkItem enriches a Job with target details so the agent knows which connector to use.
|
||||
// Returned by GET /api/v1/agents/{id}/work.
|
||||
type WorkItem struct {
|
||||
ID string `json:"id"`
|
||||
Type JobType `json:"type"`
|
||||
CertificateID string `json:"certificate_id"`
|
||||
TargetID *string `json:"target_id,omitempty"`
|
||||
TargetType string `json:"target_type,omitempty"`
|
||||
TargetConfig json.RawMessage `json:"target_config,omitempty"`
|
||||
Status JobStatus `json:"status"`
|
||||
}
|
||||
|
||||
+115
-8
@@ -17,6 +17,7 @@ type AgentService struct {
|
||||
agentRepo repository.AgentRepository
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
targetRepo repository.TargetRepository
|
||||
auditService *AuditService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
}
|
||||
@@ -26,6 +27,7 @@ func NewAgentService(
|
||||
agentRepo repository.AgentRepository,
|
||||
certRepo repository.CertificateRepository,
|
||||
jobRepo repository.JobRepository,
|
||||
targetRepo repository.TargetRepository,
|
||||
auditService *AuditService,
|
||||
issuerRegistry map[string]IssuerConnector,
|
||||
) *AgentService {
|
||||
@@ -33,6 +35,7 @@ func NewAgentService(
|
||||
agentRepo: agentRepo,
|
||||
certRepo: certRepo,
|
||||
jobRepo: jobRepo,
|
||||
targetRepo: targetRepo,
|
||||
auditService: auditService,
|
||||
issuerRegistry: issuerRegistry,
|
||||
}
|
||||
@@ -103,6 +106,8 @@ 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.
|
||||
func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID string, csrPEM []byte) error {
|
||||
// Fetch agent
|
||||
agent, err := s.agentRepo.Get(ctx, agentID)
|
||||
@@ -110,16 +115,54 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str
|
||||
return fmt.Errorf("failed to fetch agent: %w", err)
|
||||
}
|
||||
|
||||
// Validate CSR format (basic check)
|
||||
// Validate CSR format
|
||||
if len(csrPEM) == 0 {
|
||||
return fmt.Errorf("invalid CSR: empty")
|
||||
}
|
||||
|
||||
// In production, parse and validate the CSR signature and CN here
|
||||
// For now, accept and proceed
|
||||
// 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)
|
||||
}
|
||||
|
||||
// In a production system, we'd store the CSR in a certificate version or metadata
|
||||
// For now, we just validate and accept it
|
||||
// Look up the issuer connector
|
||||
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(),
|
||||
}
|
||||
|
||||
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()
|
||||
cert.LastRenewalAt = &now
|
||||
cert.UpdatedAt = now
|
||||
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||
fmt.Printf("failed to update certificate: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
if err := s.auditService.RecordEvent(ctx, agent.ID, domain.ActorTypeAgent,
|
||||
@@ -305,14 +348,78 @@ func (s *AgentService) RegisterAgent(agent domain.Agent) (*domain.Agent, error)
|
||||
}
|
||||
|
||||
// CSRSubmit processes a CSR submission from an agent (handler interface method).
|
||||
// The csrPEM parameter contains "certID:csrPEM" or just the CSR PEM.
|
||||
func (s *AgentService) CSRSubmit(agentID string, csrPEM string) (string, error) {
|
||||
// For the handler interface, we accept the CSR as a string
|
||||
err := s.SubmitCSR(context.Background(), agentID, "", []byte(csrPEM))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Return the CSR as acknowledgment
|
||||
return csrPEM, nil
|
||||
return "csr_accepted", nil
|
||||
}
|
||||
|
||||
// CSRSubmitForCert processes a CSR submission for a specific certificate (handler interface method).
|
||||
func (s *AgentService) CSRSubmitForCert(agentID string, certID string, csrPEM string) (string, error) {
|
||||
err := s.SubmitCSR(context.Background(), agentID, certID, []byte(csrPEM))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "csr_signed", nil
|
||||
}
|
||||
|
||||
// GetWork returns pending deployment jobs for an agent (handler interface method).
|
||||
func (s *AgentService) GetWork(agentID string) ([]domain.Job, error) {
|
||||
jobs, err := s.GetPendingWork(context.Background(), agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []domain.Job
|
||||
for _, j := range jobs {
|
||||
if j != nil {
|
||||
result = append(result, *j)
|
||||
}
|
||||
}
|
||||
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.
|
||||
func (s *AgentService) GetWorkWithTargets(agentID string) ([]domain.WorkItem, error) {
|
||||
jobs, err := s.GetPendingWork(context.Background(), agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var items []domain.WorkItem
|
||||
for _, j := range jobs {
|
||||
if j == nil {
|
||||
continue
|
||||
}
|
||||
item := domain.WorkItem{
|
||||
ID: j.ID,
|
||||
Type: j.Type,
|
||||
CertificateID: j.CertificateID,
|
||||
TargetID: j.TargetID,
|
||||
Status: j.Status,
|
||||
}
|
||||
|
||||
// Enrich with target details if target ID is present
|
||||
if j.TargetID != nil && *j.TargetID != "" {
|
||||
target, err := s.targetRepo.Get(context.Background(), *j.TargetID)
|
||||
if err == nil {
|
||||
item.TargetType = string(target.Type)
|
||||
item.TargetConfig = target.Config
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// UpdateJobStatus reports a job's status from an agent (handler interface method).
|
||||
func (s *AgentService) UpdateJobStatus(agentID string, jobID string, status string, errMsg string) error {
|
||||
return s.ReportJobStatus(context.Background(), agentID, jobID, domain.JobStatus(status), errMsg)
|
||||
}
|
||||
|
||||
// CertificatePickup retrieves a certificate for an agent (handler interface method).
|
||||
|
||||
@@ -279,7 +279,7 @@ func (s *DeploymentService) MarkDeploymentFailed(ctx context.Context, jobID stri
|
||||
}
|
||||
|
||||
// Send deployment failure notification
|
||||
if err := s.notificationSvc.SendDeploymentNotification(ctx, cert, target, false, fmt.Errorf(errMsg)); err != nil {
|
||||
if err := s.notificationSvc.SendDeploymentNotification(ctx, cert, target, false, fmt.Errorf("%s", errMsg)); err != nil {
|
||||
fmt.Printf("failed to send deployment notification: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// IssuerConnectorAdapter bridges the connector-layer issuer.Connector interface with the
|
||||
// service-layer IssuerConnector interface. This maintains dependency inversion: the service
|
||||
// layer defines the interface it needs, and this adapter wraps the concrete connector.
|
||||
type IssuerConnectorAdapter struct {
|
||||
connector issuer.Connector
|
||||
}
|
||||
|
||||
// NewIssuerConnectorAdapter wraps an issuer.Connector to implement service.IssuerConnector.
|
||||
func NewIssuerConnectorAdapter(c issuer.Connector) IssuerConnector {
|
||||
return &IssuerConnectorAdapter{connector: c}
|
||||
}
|
||||
|
||||
// IssueCertificate delegates to the underlying connector's IssueCertificate method,
|
||||
// translating between service-layer and connector-layer types.
|
||||
func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) {
|
||||
result, err := a.connector.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: commonName,
|
||||
SANs: sans,
|
||||
CSRPEM: csrPEM,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &IssuanceResult{
|
||||
CertPEM: result.CertPEM,
|
||||
ChainPEM: result.ChainPEM,
|
||||
Serial: result.Serial,
|
||||
NotBefore: result.NotBefore,
|
||||
NotAfter: result.NotAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewCertificate delegates to the underlying connector's RenewCertificate method,
|
||||
// translating between service-layer and connector-layer types.
|
||||
func (a *IssuerConnectorAdapter) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) {
|
||||
result, err := a.connector.RenewCertificate(ctx, issuer.RenewalRequest{
|
||||
CommonName: commonName,
|
||||
SANs: sans,
|
||||
CSRPEM: csrPEM,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &IssuanceResult{
|
||||
CertPEM: result.CertPEM,
|
||||
ChainPEM: result.ChainPEM,
|
||||
Serial: result.Serial,
|
||||
NotBefore: result.NotBefore,
|
||||
NotAfter: result.NotAfter,
|
||||
}, nil
|
||||
}
|
||||
+6
-13
@@ -95,22 +95,15 @@ func (s *JobService) processJob(ctx context.Context, job *domain.Job) error {
|
||||
}
|
||||
|
||||
// processIssuanceJob handles a certificate issuance job.
|
||||
// This is a placeholder that documents the flow.
|
||||
// TODO: Implement actual issuance job processing if needed.
|
||||
// It reuses the renewal service's ProcessRenewalJob since the flow is identical:
|
||||
// generate key → create CSR → call issuer → store version → create deployment jobs.
|
||||
// The only difference is semantics (new cert vs renewed cert), not mechanics.
|
||||
func (s *JobService) processIssuanceJob(ctx context.Context, job *domain.Job) error {
|
||||
s.logger.Debug("processing issuance job", "job_id", job.ID)
|
||||
|
||||
// TODO: Implement issuance job processing
|
||||
// In production:
|
||||
// 1. Fetch the certificate
|
||||
// 2. Fetch the issuer
|
||||
// 3. Generate or retrieve CSR
|
||||
// 4. Call issuer to issue new certificate
|
||||
// 5. Create certificate version
|
||||
// 6. Update certificate status
|
||||
// 7. Mark job as completed
|
||||
|
||||
return fmt.Errorf("issuance job processing not yet implemented")
|
||||
// Issuance follows the same code path as renewal for the Local CA:
|
||||
// generate server-side key + CSR → sign via issuer → store cert version → deploy
|
||||
return s.renewalService.ProcessRenewalJob(ctx, job)
|
||||
}
|
||||
|
||||
// processValidationJob handles a certificate validation job.
|
||||
|
||||
+159
-47
@@ -2,6 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -11,19 +18,30 @@ import (
|
||||
|
||||
// RenewalService manages certificate renewal workflows.
|
||||
type RenewalService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
auditService *AuditService
|
||||
notificationSvc *NotificationService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
auditService *AuditService
|
||||
notificationSvc *NotificationService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
}
|
||||
|
||||
// IssuerConnector defines the interface for interacting with certificate issuers.
|
||||
// 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 {
|
||||
// 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)
|
||||
// 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.
|
||||
@@ -72,12 +90,29 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
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(),
|
||||
}
|
||||
@@ -87,6 +122,12 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
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,
|
||||
@@ -96,7 +137,13 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessRenewalJob executes a renewal job: call issuer, store new version, update cert status.
|
||||
// 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 {
|
||||
@@ -106,40 +153,59 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
||||
// 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)
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
s.failJob(ctx, job, fmt.Sprintf("issuer connector not found for %s", issuerID))
|
||||
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)
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
@@ -152,38 +218,63 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
||||
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: fmt.Sprintf("renewed-%d", time.Now().Unix()),
|
||||
PEMChain: string(certPEM),
|
||||
CreatedAt: time.Now(),
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
s.failJob(ctx, job, fmt.Sprintf("version creation failed: %v", err))
|
||||
return fmt.Errorf("failed to create certificate version: %w", err)
|
||||
}
|
||||
|
||||
// Update certificate status
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
s.failJob(ctx, job, fmt.Sprintf("cert update failed: %v", err))
|
||||
return fmt.Errorf("failed to update certificate: %w", err)
|
||||
}
|
||||
|
||||
// Mark job as completed
|
||||
// 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)
|
||||
@@ -192,12 +283,33 @@ func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job)
|
||||
// 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})
|
||||
map[string]interface{}{
|
||||
"job_id": job.ID,
|
||||
"serial": result.Serial,
|
||||
"not_after": result.NotAfter,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retry attempts to reprocess failed renewal jobs with exponential backoff.
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user