Initial scaffold: certificate control plane v0.1.0

This commit is contained in:
shankar0123
2026-03-14 08:22:17 -04:00
commit d395776a95
57 changed files with 9548 additions and 0 deletions
+233
View File
@@ -0,0 +1,233 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// AgentService defines the service interface for agent operations.
type AgentService interface {
ListAgents(page, perPage int) ([]domain.Agent, int64, error)
GetAgent(id string) (*domain.Agent, error)
RegisterAgent(agent domain.Agent) (*domain.Agent, error)
Heartbeat(agentID string) error
CSRSubmit(agentID string, csrPEM string) (string, error)
CertificatePickup(agentID, certID string) (string, error)
}
// AgentHandler handles HTTP requests for agent operations.
type AgentHandler struct {
svc AgentService
}
// NewAgentHandler creates a new AgentHandler with a service dependency.
func NewAgentHandler(svc AgentService) AgentHandler {
return AgentHandler{svc: svc}
}
// ListAgents lists all registered agents.
// GET /api/v1/agents?page=1&per_page=50
func (h AgentHandler) ListAgents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
agents, total, err := h.svc.ListAgents(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list agents", requestID)
return
}
response := PagedResponse{
Data: agents,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetAgent retrieves a single agent by ID.
// GET /api/v1/agents/{id}
func (h AgentHandler) GetAgent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID is required", requestID)
return
}
id = parts[0]
agent, err := h.svc.GetAgent(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
return
}
JSON(w, http.StatusOK, agent)
}
// RegisterAgent registers a new agent.
// POST /api/v1/agents
func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var agent domain.Agent
if err := json.NewDecoder(r.Body).Decode(&agent); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.RegisterAgent(agent)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// Heartbeat records a heartbeat from an agent.
// POST /api/v1/agents/{id}/heartbeat
func (h AgentHandler) Heartbeat(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 from path /api/v1/agents/{id}/heartbeat
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]
if err := h.svc.Heartbeat(agentID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
return
}
response := map[string]string{
"status": "heartbeat_recorded",
}
JSON(w, http.StatusOK, response)
}
// AgentCSRSubmit receives a Certificate Signing Request from an agent.
// POST /api/v1/agents/{id}/csr
func (h AgentHandler) AgentCSRSubmit(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 from path /api/v1/agents/{id}/csr
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]
var req struct {
CSRPEM string `json:"csr_pem"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
if req.CSRPEM == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "CSR PEM is required", requestID)
return
}
jobID, 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",
}
JSON(w, http.StatusAccepted, response)
}
// AgentCertificatePickup allows an agent to retrieve an issued certificate.
// GET /api/v1/agents/{id}/certificates/{cert_id}
func (h AgentHandler) AgentCertificatePickup(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 and certificate ID from path /api/v1/agents/{id}/certificates/{cert_id}
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 Certificate ID are required", requestID)
return
}
agentID := parts[0]
certID := parts[2]
certPEM, err := h.svc.CertificatePickup(agentID, certID)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found or not ready", requestID)
return
}
response := map[string]string{
"certificate_pem": certPEM,
}
JSON(w, http.StatusOK, response)
}
+93
View File
@@ -0,0 +1,93 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// AuditService defines the service interface for audit event operations.
type AuditService interface {
ListAuditEvents(page, perPage int) ([]domain.AuditEvent, int64, error)
GetAuditEvent(id string) (*domain.AuditEvent, error)
}
// AuditHandler handles HTTP requests for audit event operations.
type AuditHandler struct {
svc AuditService
}
// NewAuditHandler creates a new AuditHandler with a service dependency.
func NewAuditHandler(svc AuditService) AuditHandler {
return AuditHandler{svc: svc}
}
// ListAuditEvents lists audit events.
// GET /api/v1/audit?page=1&per_page=50
func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
events, total, err := h.svc.ListAuditEvents(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list audit events", requestID)
return
}
response := PagedResponse{
Data: events,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetAuditEvent retrieves a single audit event by ID.
// GET /api/v1/audit/{id}
func (h AuditHandler) GetAuditEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/audit/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Audit event ID is required", requestID)
return
}
id = parts[0]
event, err := h.svc.GetAuditEvent(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Audit event not found", requestID)
return
}
JSON(w, http.StatusOK, event)
}
+306
View File
@@ -0,0 +1,306 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// CertificateService defines the service interface for certificate operations.
type CertificateService interface {
ListCertificates(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error)
GetCertificate(id string) (*domain.ManagedCertificate, error)
CreateCertificate(cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
UpdateCertificate(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
ArchiveCertificate(id string) error
GetCertificateVersions(certID string, page, perPage int) ([]domain.CertificateVersion, int64, error)
TriggerRenewal(certID string) error
TriggerDeployment(certID string, targetID string) error
}
// CertificateHandler handles HTTP requests for certificate operations.
type CertificateHandler struct {
svc CertificateService
}
// NewCertificateHandler creates a new CertificateHandler with a service dependency.
func NewCertificateHandler(svc CertificateService) CertificateHandler {
return CertificateHandler{svc: svc}
}
// ListCertificates lists certificates with optional filtering.
// GET /api/v1/certificates?status=Active&environment=prod&owner_id=...&team_id=...&issuer_id=...&page=1&per_page=50
func (h CertificateHandler) ListCertificates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
// Parse query parameters
query := r.URL.Query()
status := query.Get("status")
environment := query.Get("environment")
ownerID := query.Get("owner_id")
teamID := query.Get("team_id")
issuerID := query.Get("issuer_id")
page := 1
perPage := 50
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
certs, total, err := h.svc.ListCertificates(status, environment, ownerID, teamID, issuerID, page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list certificates", requestID)
return
}
response := PagedResponse{
Data: certs,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetCertificate retrieves a single certificate by ID.
// GET /api/v1/certificates/{id}
func (h CertificateHandler) GetCertificate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
if id == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
cert, err := h.svc.GetCertificate(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
JSON(w, http.StatusOK, cert)
}
// CreateCertificate creates a new certificate.
// POST /api/v1/certificates
func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var cert domain.ManagedCertificate
if err := json.NewDecoder(r.Body).Decode(&cert); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreateCertificate(cert)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateCertificate updates an existing certificate.
// PUT /api/v1/certificates/{id}
func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
id = parts[0]
var cert domain.ManagedCertificate
if err := json.NewDecoder(r.Body).Decode(&cert); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateCertificate(id, cert)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// ArchiveCertificate archives a certificate (soft delete).
// DELETE /api/v1/certificates/{id}
func (h CertificateHandler) ArchiveCertificate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
if id == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
if err := h.svc.ArchiveCertificate(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to archive certificate", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetCertificateVersions retrieves version history for a certificate.
// GET /api/v1/certificates/{id}/versions
func (h CertificateHandler) GetCertificateVersions(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 certificate ID from path /api/v1/certificates/{id}/versions
path := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
certID := parts[0]
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
versions, total, err := h.svc.GetCertificateVersions(certID, page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
return
}
response := PagedResponse{
Data: versions,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// TriggerRenewal triggers manual renewal for a certificate.
// POST /api/v1/certificates/{id}/renew
func (h CertificateHandler) TriggerRenewal(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 certificate ID from path /api/v1/certificates/{id}/renew
path := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
certID := parts[0]
if err := h.svc.TriggerRenewal(certID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger renewal", requestID)
return
}
response := map[string]string{
"status": "renewal_triggered",
}
JSON(w, http.StatusAccepted, response)
}
// TriggerDeployment triggers deployment of a certificate to targets.
// POST /api/v1/certificates/{id}/deploy
func (h CertificateHandler) TriggerDeployment(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 certificate ID from path /api/v1/certificates/{id}/deploy
path := strings.TrimPrefix(r.URL.Path, "/api/v1/certificates/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID)
return
}
certID := parts[0]
// Optional: parse request body for specific target ID
var req struct {
TargetID string `json:"target_id,omitempty"`
}
if r.Header.Get("Content-Type") == "application/json" {
json.NewDecoder(r.Body).Decode(&req)
}
if err := h.svc.TriggerDeployment(certID, req.TargetID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger deployment", requestID)
return
}
response := map[string]string{
"status": "deployment_triggered",
}
JSON(w, http.StatusAccepted, response)
}
+43
View File
@@ -0,0 +1,43 @@
package handler
import (
"net/http"
)
// HealthHandler handles health and readiness check endpoints.
type HealthHandler struct{}
// NewHealthHandler creates a new HealthHandler.
func NewHealthHandler() HealthHandler {
return HealthHandler{}
}
// Health responds with a simple health check indicating the service is alive.
// GET /health
func (h HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]string{
"status": "healthy",
}
JSON(w, http.StatusOK, response)
}
// Ready responds with readiness status, indicating whether the service is ready to handle requests.
// GET /ready
func (h HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]string{
"status": "ready",
}
JSON(w, http.StatusOK, response)
}
+209
View File
@@ -0,0 +1,209 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// IssuerService defines the service interface for issuer operations.
type IssuerService interface {
ListIssuers(page, perPage int) ([]domain.Issuer, int64, error)
GetIssuer(id string) (*domain.Issuer, error)
CreateIssuer(issuer domain.Issuer) (*domain.Issuer, error)
UpdateIssuer(id string, issuer domain.Issuer) (*domain.Issuer, error)
DeleteIssuer(id string) error
TestConnection(id string) error
}
// IssuerHandler handles HTTP requests for issuer operations.
type IssuerHandler struct {
svc IssuerService
}
// NewIssuerHandler creates a new IssuerHandler with a service dependency.
func NewIssuerHandler(svc IssuerService) IssuerHandler {
return IssuerHandler{svc: svc}
}
// ListIssuers lists all configured issuers.
// GET /api/v1/issuers?page=1&per_page=50
func (h IssuerHandler) ListIssuers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
issuers, total, err := h.svc.ListIssuers(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list issuers", requestID)
return
}
response := PagedResponse{
Data: issuers,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetIssuer retrieves a single issuer by ID.
// GET /api/v1/issuers/{id}
func (h IssuerHandler) GetIssuer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/issuers/")
if id == "" || strings.Contains(id, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return
}
issuer, err := h.svc.GetIssuer(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Issuer not found", requestID)
return
}
JSON(w, http.StatusOK, issuer)
}
// CreateIssuer creates a new issuer configuration.
// POST /api/v1/issuers
func (h IssuerHandler) CreateIssuer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var issuer domain.Issuer
if err := json.NewDecoder(r.Body).Decode(&issuer); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreateIssuer(issuer)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create issuer", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateIssuer updates an existing issuer configuration.
// PUT /api/v1/issuers/{id}
func (h IssuerHandler) UpdateIssuer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/issuers/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return
}
id = parts[0]
var issuer domain.Issuer
if err := json.NewDecoder(r.Body).Decode(&issuer); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateIssuer(id, issuer)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update issuer", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteIssuer deletes an issuer configuration.
// DELETE /api/v1/issuers/{id}
func (h IssuerHandler) DeleteIssuer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/issuers/")
if id == "" || strings.Contains(id, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return
}
if err := h.svc.DeleteIssuer(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete issuer", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
// TestConnection tests the connection to an issuer.
// POST /api/v1/issuers/{id}/test
func (h IssuerHandler) TestConnection(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 issuer ID from path /api/v1/issuers/{id}/test
path := strings.TrimPrefix(r.URL.Path, "/api/v1/issuers/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return
}
issuerID := parts[0]
if err := h.svc.TestConnection(issuerID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Connection test failed", requestID)
return
}
response := map[string]string{
"status": "connection_successful",
}
JSON(w, http.StatusOK, response)
}
+128
View File
@@ -0,0 +1,128 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// JobService defines the service interface for job operations.
type JobService interface {
ListJobs(status, jobType string, page, perPage int) ([]domain.Job, int64, error)
GetJob(id string) (*domain.Job, error)
CancelJob(id string) error
}
// JobHandler handles HTTP requests for job operations.
type JobHandler struct {
svc JobService
}
// NewJobHandler creates a new JobHandler with a service dependency.
func NewJobHandler(svc JobService) JobHandler {
return JobHandler{svc: svc}
}
// ListJobs lists jobs with optional filtering by status and type.
// GET /api/v1/jobs?status=Pending&type=Renewal&page=1&per_page=50
func (h JobHandler) ListJobs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
query := r.URL.Query()
status := query.Get("status")
jobType := query.Get("type")
page := 1
perPage := 50
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
jobs, total, err := h.svc.ListJobs(status, jobType, page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list jobs", requestID)
return
}
response := PagedResponse{
Data: jobs,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetJob retrieves a single job by ID.
// GET /api/v1/jobs/{id}
func (h JobHandler) GetJob(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/jobs/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Job ID is required", requestID)
return
}
id = parts[0]
job, err := h.svc.GetJob(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Job not found", requestID)
return
}
JSON(w, http.StatusOK, job)
}
// CancelJob cancels a job.
// POST /api/v1/jobs/{id}/cancel
func (h JobHandler) CancelJob(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 job ID from path /api/v1/jobs/{id}/cancel
path := strings.TrimPrefix(r.URL.Path, "/api/v1/jobs/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Job ID is required", requestID)
return
}
jobID := parts[0]
if err := h.svc.CancelJob(jobID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to cancel job", requestID)
return
}
response := map[string]string{
"status": "job_cancelled",
}
JSON(w, http.StatusOK, response)
}
+125
View File
@@ -0,0 +1,125 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// NotificationService defines the service interface for notification operations.
type NotificationService interface {
ListNotifications(page, perPage int) ([]domain.NotificationEvent, int64, error)
GetNotification(id string) (*domain.NotificationEvent, error)
MarkAsRead(id string) error
}
// NotificationHandler handles HTTP requests for notification operations.
type NotificationHandler struct {
svc NotificationService
}
// NewNotificationHandler creates a new NotificationHandler with a service dependency.
func NewNotificationHandler(svc NotificationService) NotificationHandler {
return NotificationHandler{svc: svc}
}
// ListNotifications lists notifications.
// GET /api/v1/notifications?page=1&per_page=50
func (h NotificationHandler) ListNotifications(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
notifications, total, err := h.svc.ListNotifications(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list notifications", requestID)
return
}
response := PagedResponse{
Data: notifications,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetNotification retrieves a single notification by ID.
// GET /api/v1/notifications/{id}
func (h NotificationHandler) GetNotification(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/notifications/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Notification ID is required", requestID)
return
}
id = parts[0]
notification, err := h.svc.GetNotification(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Notification not found", requestID)
return
}
JSON(w, http.StatusOK, notification)
}
// MarkAsRead marks a notification as read.
// POST /api/v1/notifications/{id}/read
func (h NotificationHandler) MarkAsRead(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 notification ID from path /api/v1/notifications/{id}/read
path := strings.TrimPrefix(r.URL.Path, "/api/v1/notifications/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Notification ID is required", requestID)
return
}
notificationID := parts[0]
if err := h.svc.MarkAsRead(notificationID); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to mark notification as read", requestID)
return
}
response := map[string]string{
"status": "marked_as_read",
}
JSON(w, http.StatusOK, response)
}
+181
View File
@@ -0,0 +1,181 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// OwnerService defines the service interface for owner operations.
type OwnerService interface {
ListOwners(page, perPage int) ([]domain.Owner, int64, error)
GetOwner(id string) (*domain.Owner, error)
CreateOwner(owner domain.Owner) (*domain.Owner, error)
UpdateOwner(id string, owner domain.Owner) (*domain.Owner, error)
DeleteOwner(id string) error
}
// OwnerHandler handles HTTP requests for owner operations.
type OwnerHandler struct {
svc OwnerService
}
// NewOwnerHandler creates a new OwnerHandler with a service dependency.
func NewOwnerHandler(svc OwnerService) OwnerHandler {
return OwnerHandler{svc: svc}
}
// ListOwners lists all owners.
// GET /api/v1/owners?page=1&per_page=50
func (h OwnerHandler) ListOwners(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
owners, total, err := h.svc.ListOwners(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list owners", requestID)
return
}
response := PagedResponse{
Data: owners,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetOwner retrieves a single owner by ID.
// GET /api/v1/owners/{id}
func (h OwnerHandler) GetOwner(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/owners/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Owner ID is required", requestID)
return
}
id = parts[0]
owner, err := h.svc.GetOwner(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Owner not found", requestID)
return
}
JSON(w, http.StatusOK, owner)
}
// CreateOwner creates a new owner.
// POST /api/v1/owners
func (h OwnerHandler) CreateOwner(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var owner domain.Owner
if err := json.NewDecoder(r.Body).Decode(&owner); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreateOwner(owner)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create owner", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateOwner updates an existing owner.
// PUT /api/v1/owners/{id}
func (h OwnerHandler) UpdateOwner(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/owners/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Owner ID is required", requestID)
return
}
id = parts[0]
var owner domain.Owner
if err := json.NewDecoder(r.Body).Decode(&owner); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateOwner(id, owner)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update owner", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteOwner deletes an owner.
// DELETE /api/v1/owners/{id}
func (h OwnerHandler) DeleteOwner(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/owners/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Owner ID is required", requestID)
return
}
id = parts[0]
if err := h.svc.DeleteOwner(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete owner", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
+231
View File
@@ -0,0 +1,231 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// PolicyService defines the service interface for policy rule operations.
type PolicyService interface {
ListPolicies(page, perPage int) ([]domain.PolicyRule, int64, error)
GetPolicy(id string) (*domain.PolicyRule, error)
CreatePolicy(policy domain.PolicyRule) (*domain.PolicyRule, error)
UpdatePolicy(id string, policy domain.PolicyRule) (*domain.PolicyRule, error)
DeletePolicy(id string) error
ListViolations(policyID string, page, perPage int) ([]domain.PolicyViolation, int64, error)
}
// PolicyHandler handles HTTP requests for policy rule operations.
type PolicyHandler struct {
svc PolicyService
}
// NewPolicyHandler creates a new PolicyHandler with a service dependency.
func NewPolicyHandler(svc PolicyService) PolicyHandler {
return PolicyHandler{svc: svc}
}
// ListPolicies lists all policy rules.
// GET /api/v1/policies?page=1&per_page=50
func (h PolicyHandler) ListPolicies(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
policies, total, err := h.svc.ListPolicies(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list policies", requestID)
return
}
response := PagedResponse{
Data: policies,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetPolicy retrieves a single policy rule by ID.
// GET /api/v1/policies/{id}
func (h PolicyHandler) GetPolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/policies/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Policy ID is required", requestID)
return
}
id = parts[0]
policy, err := h.svc.GetPolicy(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Policy not found", requestID)
return
}
JSON(w, http.StatusOK, policy)
}
// CreatePolicy creates a new policy rule.
// POST /api/v1/policies
func (h PolicyHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var policy domain.PolicyRule
if err := json.NewDecoder(r.Body).Decode(&policy); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreatePolicy(policy)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create policy", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdatePolicy updates an existing policy rule.
// PUT /api/v1/policies/{id}
func (h PolicyHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/policies/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Policy ID is required", requestID)
return
}
id = parts[0]
var policy domain.PolicyRule
if err := json.NewDecoder(r.Body).Decode(&policy); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdatePolicy(id, policy)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update policy", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeletePolicy deletes a policy rule.
// DELETE /api/v1/policies/{id}
func (h PolicyHandler) DeletePolicy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/policies/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Policy ID is required", requestID)
return
}
id = parts[0]
if err := h.svc.DeletePolicy(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete policy", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListViolations lists policy violations for a specific policy rule.
// GET /api/v1/policies/{id}/violations?page=1&per_page=50
func (h PolicyHandler) ListViolations(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 policy ID from path /api/v1/policies/{id}/violations
path := strings.TrimPrefix(r.URL.Path, "/api/v1/policies/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Policy ID is required", requestID)
return
}
policyID := parts[0]
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
violations, total, err := h.svc.ListViolations(policyID, page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list violations", requestID)
return
}
response := PagedResponse{
Data: violations,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
+51
View File
@@ -0,0 +1,51 @@
package handler
import (
"encoding/json"
"net/http"
)
// PagedResponse represents a paginated API response.
type PagedResponse struct {
Data interface{} `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// ErrorResponse represents a standard error response.
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
}
// JSON writes a JSON response with the given status code and data.
func JSON(w http.ResponseWriter, status int, data interface{}) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(data)
}
// Error writes a JSON error response with the given status code and message.
func Error(w http.ResponseWriter, status int, message string) error {
errResp := ErrorResponse{
Error: http.StatusText(status),
Message: message,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(errResp)
}
// ErrorWithRequestID writes a JSON error response including a request ID.
func ErrorWithRequestID(w http.ResponseWriter, status int, message, requestID string) error {
errResp := ErrorResponse{
Error: http.StatusText(status),
Message: message,
RequestID: requestID,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
return json.NewEncoder(w).Encode(errResp)
}
+177
View File
@@ -0,0 +1,177 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// TargetService defines the service interface for deployment target operations.
type TargetService interface {
ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error)
GetTarget(id string) (*domain.DeploymentTarget, error)
CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
DeleteTarget(id string) error
}
// TargetHandler handles HTTP requests for deployment target operations.
type TargetHandler struct {
svc TargetService
}
// NewTargetHandler creates a new TargetHandler with a service dependency.
func NewTargetHandler(svc TargetService) TargetHandler {
return TargetHandler{svc: svc}
}
// ListTargets lists all deployment targets.
// GET /api/v1/targets?page=1&per_page=50
func (h TargetHandler) ListTargets(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
targets, total, err := h.svc.ListTargets(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list targets", requestID)
return
}
response := PagedResponse{
Data: targets,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetTarget retrieves a single deployment target by ID.
// GET /api/v1/targets/{id}
func (h TargetHandler) GetTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/")
if id == "" || strings.Contains(id, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID)
return
}
target, err := h.svc.GetTarget(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Target not found", requestID)
return
}
JSON(w, http.StatusOK, target)
}
// CreateTarget creates a new deployment target.
// POST /api/v1/targets
func (h TargetHandler) CreateTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var target domain.DeploymentTarget
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreateTarget(target)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create target", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateTarget updates an existing deployment target.
// PUT /api/v1/targets/{id}
func (h TargetHandler) UpdateTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID)
return
}
id = parts[0]
var target domain.DeploymentTarget
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateTarget(id, target)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update target", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteTarget deletes a deployment target.
// DELETE /api/v1/targets/{id}
func (h TargetHandler) DeleteTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/")
if id == "" || strings.Contains(id, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID)
return
}
if err := h.svc.DeleteTarget(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete target", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
+181
View File
@@ -0,0 +1,181 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// TeamService defines the service interface for team operations.
type TeamService interface {
ListTeams(page, perPage int) ([]domain.Team, int64, error)
GetTeam(id string) (*domain.Team, error)
CreateTeam(team domain.Team) (*domain.Team, error)
UpdateTeam(id string, team domain.Team) (*domain.Team, error)
DeleteTeam(id string) error
}
// TeamHandler handles HTTP requests for team operations.
type TeamHandler struct {
svc TeamService
}
// NewTeamHandler creates a new TeamHandler with a service dependency.
func NewTeamHandler(svc TeamService) TeamHandler {
return TeamHandler{svc: svc}
}
// ListTeams lists all teams.
// GET /api/v1/teams?page=1&per_page=50
func (h TeamHandler) ListTeams(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
page := 1
perPage := 50
query := r.URL.Query()
if p := query.Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := query.Get("per_page"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 500 {
perPage = parsed
}
}
teams, total, err := h.svc.ListTeams(page, perPage)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list teams", requestID)
return
}
response := PagedResponse{
Data: teams,
Total: total,
Page: page,
PerPage: perPage,
}
JSON(w, http.StatusOK, response)
}
// GetTeam retrieves a single team by ID.
// GET /api/v1/teams/{id}
func (h TeamHandler) GetTeam(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/teams/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Team ID is required", requestID)
return
}
id = parts[0]
team, err := h.svc.GetTeam(id)
if err != nil {
ErrorWithRequestID(w, http.StatusNotFound, "Team not found", requestID)
return
}
JSON(w, http.StatusOK, team)
}
// CreateTeam creates a new team.
// POST /api/v1/teams
func (h TeamHandler) CreateTeam(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var team domain.Team
if err := json.NewDecoder(r.Body).Decode(&team); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
created, err := h.svc.CreateTeam(team)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create team", requestID)
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateTeam updates an existing team.
// PUT /api/v1/teams/{id}
func (h TeamHandler) UpdateTeam(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/teams/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Team ID is required", requestID)
return
}
id = parts[0]
var team domain.Team
if err := json.NewDecoder(r.Body).Decode(&team); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
updated, err := h.svc.UpdateTeam(id, team)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update team", requestID)
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteTeam deletes a team.
// DELETE /api/v1/teams/{id}
func (h TeamHandler) DeleteTeam(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
id := strings.TrimPrefix(r.URL.Path, "/api/v1/teams/")
parts := strings.Split(id, "/")
if len(parts) == 0 || parts[0] == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "Team ID is required", requestID)
return
}
id = parts[0]
if err := h.svc.DeleteTeam(id); err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to delete team", requestID)
return
}
w.WriteHeader(http.StatusNoContent)
}
+144
View File
@@ -0,0 +1,144 @@
package middleware
import (
"context"
"log"
"net/http"
"time"
"github.com/google/uuid"
)
// RequestIDKey is the context key for storing request IDs.
type RequestIDKey struct{}
// UserKey is the context key for storing authenticated user information.
type UserKey struct{}
// RequestID middleware generates a unique request ID and adds it to the request context and response headers.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), RequestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Logging middleware logs request details including method, path, status, and duration.
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap response writer to capture status code
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
requestID := getRequestID(r.Context())
log.Printf("[%s] %s %s %d %v", requestID, r.Method, r.URL.Path, wrapped.statusCode, duration)
})
}
// Recovery middleware recovers from panics and returns a 500 error.
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
requestID := getRequestID(r.Context())
log.Printf("[%s] PANIC: %v", requestID, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Auth middleware is a placeholder that checks the Authorization header and extracts user information.
// In production, this would validate tokens, verify signatures, etc.
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
// For now, allow requests without auth (placeholder)
// In production, enforce auth on protected routes
next.ServeHTTP(w, r)
return
}
// Simple stub: just extract user ID from Bearer token (format: "Bearer <user_id>")
// This is NOT secure and for development only
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
userID := authHeader[7:]
ctx := context.WithValue(r.Context(), UserKey{}, userID)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
http.Error(w, "Invalid Authorization header", http.StatusUnauthorized)
})
}
// ContentType middleware sets the Content-Type header to application/json.
func ContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
next.ServeHTTP(w, r)
})
}
// CORS middleware adds CORS headers to allow cross-origin requests.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// GetRequestID extracts the request ID from context.
func GetRequestID(ctx context.Context) string {
return getRequestID(ctx)
}
// getRequestID is an internal helper to extract request ID from context.
func getRequestID(ctx context.Context) string {
id, ok := ctx.Value(RequestIDKey{}).(string)
if !ok {
return "unknown"
}
return id
}
// GetUser extracts the authenticated user from context.
func GetUser(ctx context.Context) (string, bool) {
user, ok := ctx.Value(UserKey{}).(string)
return user, ok
}
// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Chain chains multiple middleware functions.
func Chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}
+145
View File
@@ -0,0 +1,145 @@
package router
import (
"net/http"
"github.com/shankar0123/certctl/internal/api/handler"
"github.com/shankar0123/certctl/internal/api/middleware"
)
// Router wraps http.ServeMux and manages route registration with middleware.
type Router struct {
mux *http.ServeMux
middleware []func(http.Handler) http.Handler
}
// New creates a new Router instance.
func New() *Router {
return &Router{
mux: http.NewServeMux(),
middleware: []func(http.Handler) http.Handler{},
}
}
// NewWithMiddleware creates a Router with initial middleware stack.
func NewWithMiddleware(middlewares ...func(http.Handler) http.Handler) *Router {
r := New()
r.middleware = middlewares
return r
}
// ServeHTTP implements http.Handler interface.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mux.ServeHTTP(w, req)
}
// Register registers a handler for a given path with the middleware chain applied.
func (r *Router) Register(pattern string, handler http.Handler) {
r.mux.Handle(pattern, middleware.Chain(handler, r.middleware...))
}
// RegisterFunc registers a handler function for a given path.
func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
r.Register(pattern, http.HandlerFunc(handler))
}
// RegisterHandlers sets up all API routes with their handlers.
func (r *Router) RegisterHandlers(
certificates handler.CertificateHandler,
issuers handler.IssuerHandler,
targets handler.TargetHandler,
agents handler.AgentHandler,
jobs handler.JobHandler,
policies handler.PolicyHandler,
teams handler.TeamHandler,
owners handler.OwnerHandler,
audit handler.AuditHandler,
notifications handler.NotificationHandler,
health handler.HealthHandler,
) {
// Health endpoints (no middleware)
r.mux.Handle("GET /health", middleware.Chain(
http.HandlerFunc(health.Health),
middleware.CORS,
middleware.ContentType,
))
r.mux.Handle("GET /ready", middleware.Chain(
http.HandlerFunc(health.Ready),
middleware.CORS,
middleware.ContentType,
))
// Certificates routes: /api/v1/certificates
r.Register("GET /api/v1/certificates", http.HandlerFunc(certificates.ListCertificates))
r.Register("POST /api/v1/certificates", http.HandlerFunc(certificates.CreateCertificate))
r.Register("GET /api/v1/certificates/{id}", http.HandlerFunc(certificates.GetCertificate))
r.Register("PUT /api/v1/certificates/{id}", http.HandlerFunc(certificates.UpdateCertificate))
r.Register("DELETE /api/v1/certificates/{id}", http.HandlerFunc(certificates.ArchiveCertificate))
r.Register("GET /api/v1/certificates/{id}/versions", http.HandlerFunc(certificates.GetCertificateVersions))
r.Register("POST /api/v1/certificates/{id}/renew", http.HandlerFunc(certificates.TriggerRenewal))
r.Register("POST /api/v1/certificates/{id}/deploy", http.HandlerFunc(certificates.TriggerDeployment))
// Issuers routes: /api/v1/issuers
r.Register("GET /api/v1/issuers", http.HandlerFunc(issuers.ListIssuers))
r.Register("POST /api/v1/issuers", http.HandlerFunc(issuers.CreateIssuer))
r.Register("GET /api/v1/issuers/{id}", http.HandlerFunc(issuers.GetIssuer))
r.Register("PUT /api/v1/issuers/{id}", http.HandlerFunc(issuers.UpdateIssuer))
r.Register("DELETE /api/v1/issuers/{id}", http.HandlerFunc(issuers.DeleteIssuer))
r.Register("POST /api/v1/issuers/{id}/test", http.HandlerFunc(issuers.TestConnection))
// Targets routes: /api/v1/targets
r.Register("GET /api/v1/targets", http.HandlerFunc(targets.ListTargets))
r.Register("POST /api/v1/targets", http.HandlerFunc(targets.CreateTarget))
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(targets.GetTarget))
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(targets.UpdateTarget))
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(targets.DeleteTarget))
// Agents routes: /api/v1/agents
r.Register("GET /api/v1/agents", http.HandlerFunc(agents.ListAgents))
r.Register("POST /api/v1/agents", http.HandlerFunc(agents.RegisterAgent))
r.Register("GET /api/v1/agents/{id}", http.HandlerFunc(agents.GetAgent))
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))
// Jobs routes: /api/v1/jobs
r.Register("GET /api/v1/jobs", http.HandlerFunc(jobs.ListJobs))
r.Register("GET /api/v1/jobs/{id}", http.HandlerFunc(jobs.GetJob))
r.Register("POST /api/v1/jobs/{id}/cancel", http.HandlerFunc(jobs.CancelJob))
// Policies routes: /api/v1/policies
r.Register("GET /api/v1/policies", http.HandlerFunc(policies.ListPolicies))
r.Register("POST /api/v1/policies", http.HandlerFunc(policies.CreatePolicy))
r.Register("GET /api/v1/policies/{id}", http.HandlerFunc(policies.GetPolicy))
r.Register("PUT /api/v1/policies/{id}", http.HandlerFunc(policies.UpdatePolicy))
r.Register("DELETE /api/v1/policies/{id}", http.HandlerFunc(policies.DeletePolicy))
r.Register("GET /api/v1/policies/{id}/violations", http.HandlerFunc(policies.ListViolations))
// Teams routes: /api/v1/teams
r.Register("GET /api/v1/teams", http.HandlerFunc(teams.ListTeams))
r.Register("POST /api/v1/teams", http.HandlerFunc(teams.CreateTeam))
r.Register("GET /api/v1/teams/{id}", http.HandlerFunc(teams.GetTeam))
r.Register("PUT /api/v1/teams/{id}", http.HandlerFunc(teams.UpdateTeam))
r.Register("DELETE /api/v1/teams/{id}", http.HandlerFunc(teams.DeleteTeam))
// Owners routes: /api/v1/owners
r.Register("GET /api/v1/owners", http.HandlerFunc(owners.ListOwners))
r.Register("POST /api/v1/owners", http.HandlerFunc(owners.CreateOwner))
r.Register("GET /api/v1/owners/{id}", http.HandlerFunc(owners.GetOwner))
r.Register("PUT /api/v1/owners/{id}", http.HandlerFunc(owners.UpdateOwner))
r.Register("DELETE /api/v1/owners/{id}", http.HandlerFunc(owners.DeleteOwner))
// Audit routes: /api/v1/audit
r.Register("GET /api/v1/audit", http.HandlerFunc(audit.ListAuditEvents))
r.Register("GET /api/v1/audit/{id}", http.HandlerFunc(audit.GetAuditEvent))
// Notifications routes: /api/v1/notifications
r.Register("GET /api/v1/notifications", http.HandlerFunc(notifications.ListNotifications))
r.Register("GET /api/v1/notifications/{id}", http.HandlerFunc(notifications.GetNotification))
r.Register("POST /api/v1/notifications/{id}/read", http.HandlerFunc(notifications.MarkAsRead))
}
// GetMux returns the underlying http.ServeMux for direct access if needed.
func (r *Router) GetMux() *http.ServeMux {
return r.mux
}
+209
View File
@@ -0,0 +1,209 @@
package config
import (
"fmt"
"log/slog"
"os"
"strconv"
"time"
)
// Config represents the complete application configuration.
// All configuration values are read from environment variables with CERTCTL_ prefix.
type Config struct {
Server ServerConfig
Database DatabaseConfig
Scheduler SchedulerConfig
Log LogConfig
Auth AuthConfig
}
// ServerConfig contains HTTP server configuration.
type ServerConfig struct {
Host string
Port int
}
// DatabaseConfig contains database connection configuration.
type DatabaseConfig struct {
URL string
MaxConnections int
MigrationsPath string
}
// SchedulerConfig contains scheduler timing configuration.
type SchedulerConfig struct {
RenewalCheckInterval time.Duration
JobProcessorInterval time.Duration
AgentHealthCheckInterval time.Duration
NotificationProcessInterval time.Duration
}
// LogConfig contains logging configuration.
type LogConfig struct {
Level string // "debug", "info", "warn", "error"
Format string // "json" or "text"
}
// AuthConfig contains authentication configuration.
type AuthConfig struct {
Type string // "api-key", "jwt", "none"
Secret string // Secret key for signing (if applicable)
}
// Load reads configuration from environment variables and returns a Config.
// Environment variables must have the CERTCTL_ prefix.
// Example: CERTCTL_SERVER_HOST, CERTCTL_DATABASE_URL, etc.
func Load() (*Config, error) {
cfg := &Config{
Server: ServerConfig{
Host: getEnv("CERTCTL_SERVER_HOST", "127.0.0.1"),
Port: getEnvInt("CERTCTL_SERVER_PORT", 8080),
},
Database: DatabaseConfig{
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
MaxConnections: getEnvInt("CERTCTL_DATABASE_MAX_CONNS", 25),
MigrationsPath: getEnv("CERTCTL_DATABASE_MIGRATIONS_PATH", "./migrations"),
},
Scheduler: SchedulerConfig{
RenewalCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL", 1*time.Hour),
JobProcessorInterval: getEnvDuration("CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL", 30*time.Second),
AgentHealthCheckInterval: getEnvDuration("CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL", 2*time.Minute),
NotificationProcessInterval: getEnvDuration("CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL", 1*time.Minute),
},
Log: LogConfig{
Level: getEnv("CERTCTL_LOG_LEVEL", "info"),
Format: getEnv("CERTCTL_LOG_FORMAT", "json"),
},
Auth: AuthConfig{
Type: getEnv("CERTCTL_AUTH_TYPE", "api-key"),
Secret: getEnv("CERTCTL_AUTH_SECRET", ""),
},
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// Validate checks that the configuration is valid.
func (c *Config) Validate() error {
// Validate server configuration
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("invalid server port: %d", c.Server.Port)
}
// Validate database configuration
if c.Database.URL == "" {
return fmt.Errorf("database URL is required")
}
if c.Database.MaxConnections < 1 {
return fmt.Errorf("database max_connections must be at least 1")
}
// Validate log level
validLogLevels := map[string]bool{
"debug": true,
"info": true,
"warn": true,
"error": true,
}
if !validLogLevels[c.Log.Level] {
return fmt.Errorf("invalid log level: %s", c.Log.Level)
}
// Validate log format
validFormats := map[string]bool{
"json": true,
"text": true,
}
if !validFormats[c.Log.Format] {
return fmt.Errorf("invalid log format: %s", c.Log.Format)
}
// Validate auth type
validAuthTypes := map[string]bool{
"api-key": true,
"jwt": true,
"none": true,
}
if !validAuthTypes[c.Auth.Type] {
return fmt.Errorf("invalid auth type: %s", c.Auth.Type)
}
// If using JWT or API-key, secret is required
if (c.Auth.Type == "jwt" || c.Auth.Type == "api-key") && c.Auth.Secret == "" {
return fmt.Errorf("auth secret is required for auth type %s", c.Auth.Type)
}
// Validate scheduler intervals
if c.Scheduler.RenewalCheckInterval < 1*time.Minute {
return fmt.Errorf("renewal check interval must be at least 1 minute")
}
if c.Scheduler.JobProcessorInterval < 1*time.Second {
return fmt.Errorf("job processor interval must be at least 1 second")
}
if c.Scheduler.AgentHealthCheckInterval < 1*time.Second {
return fmt.Errorf("agent health check interval must be at least 1 second")
}
if c.Scheduler.NotificationProcessInterval < 1*time.Second {
return fmt.Errorf("notification process interval must be at least 1 second")
}
return nil
}
// getEnv reads a string environment variable with the given key and default value.
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getEnvInt reads an integer environment variable with the given key and default value.
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
intVal, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return intVal
}
return defaultValue
}
// getEnvDuration reads a time.Duration environment variable.
// The value should be a valid Go duration string (e.g., "1h", "30s", "5m").
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
duration, err := time.ParseDuration(value)
if err != nil {
return defaultValue
}
return duration
}
return defaultValue
}
// GetLogLevel returns the appropriate slog.Level from the configured log level.
func (c *Config) GetLogLevel() slog.Level {
switch c.Log.Level {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
+186
View File
@@ -0,0 +1,186 @@
package acme
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"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"`
}
// 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).
type Connector struct {
config *Config
logger *slog.Logger
client *http.Client
}
// New creates a new ACME connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// 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 {
return fmt.Errorf("invalid ACME config: %w", err)
}
if cfg.DirectoryURL == "" {
return fmt.Errorf("ACME directory_url is required")
}
if cfg.Email == "" {
return fmt.Errorf("ACME email is required")
}
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)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to reach ACME directory: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ACME directory returned status %d", resp.StatusCode)
}
c.config = &cfg
c.logger.Info("ACME configuration validated")
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.
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
c.logger.Warn("ACME issuance not yet implemented", "common_name", request.CommonName)
// 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",
}, 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.
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
}
// 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
c.logger.Warn("ACME revocation not yet implemented", "serial", request.Serial)
return nil
}
// 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
c.logger.Warn("ACME order status polling not yet implemented", "order_id", orderID)
// Stub: Return a placeholder status
return &issuer.OrderStatus{
OrderID: orderID,
Status: "processing",
Message: nil,
UpdatedAt: time.Now(),
}, nil
}
+69
View File
@@ -0,0 +1,69 @@
package issuer
import (
"context"
"encoding/json"
"time"
)
// Connector defines the interface for certificate issuance operations.
type Connector interface {
// ValidateConfig validates the issuer configuration.
ValidateConfig(ctx context.Context, config json.RawMessage) error
// IssueCertificate issues a new certificate.
IssueCertificate(ctx context.Context, request IssuanceRequest) (*IssuanceResult, error)
// RenewCertificate renews an existing certificate.
RenewCertificate(ctx context.Context, request RenewalRequest) (*IssuanceResult, error)
// RevokeCertificate revokes a certificate.
RevokeCertificate(ctx context.Context, request RevocationRequest) error
// GetOrderStatus retrieves the status of an issuance or renewal order.
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
}
// IssuanceRequest contains the parameters for issuing a new certificate.
type IssuanceRequest struct {
CommonName string `json:"common_name"`
SANs []string `json:"sans"`
CSRPEM string `json:"csr_pem"`
}
// IssuanceResult contains the result of a successful certificate issuance.
type IssuanceResult struct {
CertPEM string `json:"cert_pem"`
ChainPEM string `json:"chain_pem"`
Serial string `json:"serial"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
OrderID string `json:"order_id"`
}
// RenewalRequest contains the parameters for renewing a certificate.
type RenewalRequest struct {
CommonName string `json:"common_name"`
SANs []string `json:"sans"`
CSRPEM string `json:"csr_pem"`
OrderID *string `json:"order_id,omitempty"`
}
// RevocationRequest contains the parameters for revoking a certificate.
type RevocationRequest struct {
Serial string `json:"serial"`
Reason *string `json:"reason,omitempty"`
}
// OrderStatus contains the status of a pending issuance or renewal order.
type OrderStatus struct {
OrderID string `json:"order_id"`
Status string `json:"status"`
Message *string `json:"message,omitempty"`
CertPEM *string `json:"cert_pem,omitempty"`
ChainPEM *string `json:"chain_pem,omitempty"`
Serial *string `json:"serial,omitempty"`
NotBefore *time.Time `json:"not_before,omitempty"`
NotAfter *time.Time `json:"not_after,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
+270
View File
@@ -0,0 +1,270 @@
package email
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/smtp"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/notifier"
)
// Config represents the email notifier configuration.
type Config struct {
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
Username string `json:"username"`
Password string `json:"password"`
FromAddress string `json:"from_address"`
UseTLS bool `json:"tls"`
}
// Connector implements the notifier.Connector interface for email notifications.
// It sends alert and event notifications via SMTP.
type Connector struct {
config *Config
logger *slog.Logger
}
// New creates a new email notifier with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
}
}
// ValidateConfig checks that the SMTP server is reachable and credentials are valid.
// It attempts to connect to the SMTP server 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 {
return fmt.Errorf("invalid email config: %w", err)
}
if cfg.SMTPHost == "" || cfg.SMTPPort == 0 || cfg.FromAddress == "" {
return fmt.Errorf("email smtp_host, smtp_port, and from_address are required")
}
c.logger.Info("validating email configuration",
"smtp_host", cfg.SMTPHost,
"smtp_port", cfg.SMTPPort)
// Test SMTP connectivity with timeout
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("failed to reach SMTP server %s: %w", addr, err)
}
defer conn.Close()
c.config = &cfg
c.logger.Info("email configuration validated")
return nil
}
// SendAlert sends an alert notification via SMTP.
// It formats the alert as an email message and sends it to the recipient.
func (c *Connector) SendAlert(ctx context.Context, alert notifier.Alert) error {
c.logger.Info("sending email alert",
"alert_id", alert.ID,
"severity", alert.Severity,
"recipient", alert.Recipient)
// Format email subject and body
subject := fmt.Sprintf("[%s] %s", strings.ToUpper(alert.Severity), alert.Subject)
body := c.formatAlertBody(alert)
// Send email
if err := c.sendEmail(ctx, alert.Recipient, subject, body); err != nil {
c.logger.Error("failed to send alert email",
"alert_id", alert.ID,
"error", err)
return fmt.Errorf("failed to send alert email: %w", err)
}
c.logger.Info("alert email sent successfully",
"alert_id", alert.ID,
"recipient", alert.Recipient)
return nil
}
// SendEvent sends an event notification via SMTP.
// It formats the event as an email message and sends it to the recipient.
func (c *Connector) SendEvent(ctx context.Context, event notifier.Event) error {
c.logger.Info("sending email event",
"event_id", event.ID,
"event_type", event.Type,
"recipient", event.Recipient)
// Format email subject and body
subject := fmt.Sprintf("[Event] %s", event.Subject)
body := c.formatEventBody(event)
// Send email
if err := c.sendEmail(ctx, event.Recipient, subject, body); err != nil {
c.logger.Error("failed to send event email",
"event_id", event.ID,
"error", err)
return fmt.Errorf("failed to send event email: %w", err)
}
c.logger.Info("event email sent successfully",
"event_id", event.ID,
"recipient", event.Recipient)
return nil
}
// sendEmail sends an email message using the configured SMTP server.
// It handles both TLS and plain authentication modes.
func (c *Connector) sendEmail(ctx context.Context, to, subject, body string) error {
addr := fmt.Sprintf("%s:%d", c.config.SMTPHost, c.config.SMTPPort)
// Connect to SMTP server
var auth smtp.Auth
if c.config.Username != "" && c.config.Password != "" {
auth = smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.SMTPHost)
}
var conn net.Conn
var err error
if c.config.UseTLS {
// Connect with TLS
tlsConfig := &tls.Config{
ServerName: c.config.SMTPHost,
}
conn, err = tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect via TLS: %w", err)
}
} else {
// Connect without TLS
conn, err = net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
}
defer conn.Close()
// Create SMTP client
client, err := smtp.NewClient(conn, c.config.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
// Authenticate if credentials provided
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
}
// Send email
if err := client.Mail(c.config.FromAddress); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
defer wc.Close()
// Format and write email headers and body
message := c.formatEmailMessage(c.config.FromAddress, to, subject, body)
if _, err := wc.Write(message); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err := client.Quit(); err != nil {
return fmt.Errorf("failed to quit SMTP: %w", err)
}
return nil
}
// formatEmailMessage formats an email message with standard headers.
func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte {
message := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s",
from,
to,
subject,
time.Now().Format(time.RFC1123Z),
body,
)
return []byte(message)
}
// formatAlertBody formats an alert notification as email body text.
func (c *Connector) formatAlertBody(alert notifier.Alert) string {
body := fmt.Sprintf(`
Certificate Alert Notification
================================
Alert ID: %s
Type: %s
Severity: %s
Created: %s
Subject: %s
Message:
%s
%s
`, alert.ID, alert.Type, alert.Severity, alert.CreatedAt.Format(time.RFC3339), alert.Subject, alert.Message, c.formatMetadata(alert.Metadata))
return body
}
// formatEventBody formats an event notification as email body text.
func (c *Connector) formatEventBody(event notifier.Event) string {
certInfo := ""
if event.CertificateID != nil {
certInfo = fmt.Sprintf("Certificate ID: %s\n", *event.CertificateID)
}
body := fmt.Sprintf(`
Certificate Event Notification
================================
Event ID: %s
Type: %s
Created: %s
%sSubject: %s
Body:
%s
%s
`, event.ID, event.Type, event.CreatedAt.Format(time.RFC3339), certInfo, event.Subject, event.Body, c.formatMetadata(event.Metadata))
return body
}
// formatMetadata formats metadata as a readable string.
func (c *Connector) formatMetadata(metadata map[string]string) string {
if len(metadata) == 0 {
return ""
}
metadataStr := "\nMetadata:\n"
for key, value := range metadata {
metadataStr += fmt.Sprintf(" %s: %s\n", key, value)
}
return metadataStr
}
+43
View File
@@ -0,0 +1,43 @@
package notifier
import (
"context"
"encoding/json"
"time"
)
// Connector defines the interface for sending notifications about certificate events.
type Connector interface {
// ValidateConfig validates the notifier configuration.
ValidateConfig(ctx context.Context, config json.RawMessage) error
// SendAlert sends an alert notification.
SendAlert(ctx context.Context, alert Alert) error
// SendEvent sends an event notification.
SendEvent(ctx context.Context, event Event) error
}
// Alert represents a notification alert with urgency.
type Alert struct {
ID string `json:"id"`
Type string `json:"type"`
Severity string `json:"severity"`
Subject string `json:"subject"`
Message string `json:"message"`
Recipient string `json:"recipient"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Event represents a notification event with contextual information.
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
CertificateID *string `json:"certificate_id,omitempty"`
Recipient string `json:"recipient"`
Subject string `json:"subject"`
Body string `json:"body"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -0,0 +1,211 @@
package webhook
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/connector/notifier"
)
// Config represents the webhook notifier configuration.
type Config struct {
URL string `json:"url"`
Secret string `json:"secret,omitempty"` // Secret for HMAC-SHA256 signature
Headers map[string]string `json:"headers,omitempty"` // Custom headers to include
}
// Connector implements the notifier.Connector interface for webhook notifications.
// It sends alert and event notifications via HTTP POST with optional HMAC signing.
type Connector struct {
config *Config
logger *slog.Logger
client *http.Client
}
// New creates a new webhook notifier with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// ValidateConfig checks that the webhook URL is valid and reachable.
// It performs a test request to verify the endpoint is accessible.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid webhook config: %w", err)
}
if cfg.URL == "" {
return fmt.Errorf("webhook url is required")
}
c.logger.Info("validating webhook configuration", "url", cfg.URL)
// Test webhook connectivity with a HEAD request
req, err := http.NewRequestWithContext(ctx, http.MethodHead, cfg.URL, nil)
if err != nil {
return fmt.Errorf("invalid webhook URL: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to reach webhook endpoint: %w", err)
}
defer resp.Body.Close()
// Accept any 2xx or 3xx status code as valid
if resp.StatusCode >= 400 {
c.logger.Warn("webhook validation: endpoint returned error status",
"status_code", resp.StatusCode)
// Still allow configuration; the endpoint might be designed to accept POST
}
c.config = &cfg
c.logger.Info("webhook configuration validated")
return nil
}
// SendAlert sends an alert notification via webhook.
// It POSTs the alert as JSON to the configured webhook URL with optional HMAC signature.
func (c *Connector) SendAlert(ctx context.Context, alert notifier.Alert) error {
c.logger.Info("sending webhook alert",
"alert_id", alert.ID,
"severity", alert.Severity)
// Format payload
payload := map[string]interface{}{
"type": "alert",
"alert_id": alert.ID,
"severity": alert.Severity,
"subject": alert.Subject,
"message": alert.Message,
"recipient": alert.Recipient,
"created_at": alert.CreatedAt,
"metadata": alert.Metadata,
}
if err := c.postWebhook(ctx, payload); err != nil {
c.logger.Error("failed to send alert via webhook",
"alert_id", alert.ID,
"error", err)
return fmt.Errorf("failed to send alert via webhook: %w", err)
}
c.logger.Info("alert sent via webhook", "alert_id", alert.ID)
return nil
}
// SendEvent sends an event notification via webhook.
// It POSTs the event as JSON to the configured webhook URL with optional HMAC signature.
func (c *Connector) SendEvent(ctx context.Context, event notifier.Event) error {
c.logger.Info("sending webhook event",
"event_id", event.ID,
"event_type", event.Type)
// Format payload
payload := map[string]interface{}{
"type": "event",
"event_id": event.ID,
"event_type": event.Type,
"subject": event.Subject,
"body": event.Body,
"recipient": event.Recipient,
"created_at": event.CreatedAt,
}
if event.CertificateID != nil {
payload["certificate_id"] = *event.CertificateID
}
if event.Metadata != nil {
payload["metadata"] = event.Metadata
}
if err := c.postWebhook(ctx, payload); err != nil {
c.logger.Error("failed to send event via webhook",
"event_id", event.ID,
"error", err)
return fmt.Errorf("failed to send event via webhook: %w", err)
}
c.logger.Info("event sent via webhook", "event_id", event.ID)
return nil
}
// postWebhook sends a payload to the webhook URL with proper headers and signing.
// If a secret is configured, it signs the payload using HMAC-SHA256 and includes
// the signature in the X-Signature header.
func (c *Connector) postWebhook(ctx context.Context, payload interface{}) error {
// Marshal payload to JSON
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
// Create request
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.URL, bytes.NewReader(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set standard headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "certctl-notifier/1.0")
// Add custom headers from configuration
for key, value := range c.config.Headers {
req.Header.Set(key, value)
}
// Sign payload if secret is configured
if c.config.Secret != "" {
signature := c.signPayload(jsonData)
req.Header.Set("X-Signature", signature)
req.Header.Set("X-Signature-Algorithm", "sha256")
}
// Send request
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send webhook request: %w", err)
}
defer resp.Body.Close()
// Read response body for error logging
respBody, _ := io.ReadAll(resp.Body)
// Accept 2xx status codes as success
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Debug("webhook request successful",
"status_code", resp.StatusCode,
"url", c.config.URL)
return nil
}
// signPayload computes an HMAC-SHA256 signature of the payload using the configured secret.
// The signature is returned as a hex-encoded string in the format "sha256=<hex>".
func (c *Connector) signPayload(data []byte) string {
h := hmac.New(sha256.New, []byte(c.config.Secret))
h.Write(data)
signature := hex.EncodeToString(h.Sum(nil))
return fmt.Sprintf("sha256=%s", signature)
}
+189
View File
@@ -0,0 +1,189 @@
package f5
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
)
// Config represents the F5 BIG-IP deployment target configuration.
type Config struct {
Host string `json:"host"` // F5 BIG-IP hostname or IP
Port int `json:"port"` // F5 iControl REST API port (default 443)
Username string `json:"username"` // Administrative username
Password string `json:"password"` // Administrative password
Partition string `json:"partition"` // F5 partition name (e.g., "Common")
SSLProfile string `json:"ssl_profile"` // SSL profile name to update
}
// Connector implements the target.Connector interface for F5 BIG-IP load balancers.
// This connector communicates with F5's iControl REST API to upload certificates and manage SSL profiles.
//
// TODO: Implement actual F5 iControl REST API communication.
// The documented API endpoints and flow are:
// - Authentication: POST /mgmt/shared/authn/login
// - Upload certificate: POST /mgmt/tm/ltm/certificate
// - Update SSL profile: PATCH /mgmt/tm/ltm/profile/client-ssl/{profile_name}
// - Check SSL profile: GET /mgmt/tm/ltm/profile/client-ssl/{profile_name}
type Connector struct {
config *Config
logger *slog.Logger
client *http.Client
}
// New creates a new F5 target connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
client: &http.Client{
Timeout: 30 * time.Second,
// TODO: Configure proper TLS verification or skip for self-signed F5 certs
},
}
}
// ValidateConfig checks that the F5 BIG-IP is reachable and credentials are valid.
// It attempts to authenticate to the F5 iControl REST API.
//
// TODO: Implement actual F5 authentication validation.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid F5 config: %w", err)
}
if cfg.Host == "" || cfg.Username == "" || cfg.Password == "" {
return fmt.Errorf("F5 host, username, and password are required")
}
if cfg.Port == 0 {
cfg.Port = 443 // Default HTTPS port
}
if cfg.Partition == "" {
cfg.Partition = "Common"
}
c.logger.Info("validating F5 configuration",
"host", cfg.Host,
"port", cfg.Port,
"partition", cfg.Partition)
// TODO: Implement F5 authentication check
// In production:
// 1. POST to https://{host}:{port}/mgmt/shared/authn/login
// 2. Send credentials in request body
// 3. Verify response contains valid authentication token
// 4. Optionally test connectivity to SSL profile endpoint
c.logger.Warn("F5 validation not yet fully implemented",
"host", cfg.Host)
c.config = &cfg
return nil
}
// DeployCertificate uploads a certificate to the F5 BIG-IP and updates the specified SSL profile.
//
// The F5 deployment process:
// 1. Authenticate to iControl REST API using credentials
// 2. Upload certificate PEM to /mgmt/tm/ltm/certificate
// 3. Upload chain PEM as separate certificate if needed
// 4. Update the target SSL profile to reference the new certificate
// 5. Verify the profile was updated successfully
//
// TODO: Implement actual F5 iControl REST API calls.
// API endpoints used:
// - POST /mgmt/shared/authn/login (authentication)
// - POST /mgmt/tm/ltm/certificate (upload cert)
// - PATCH /mgmt/tm/ltm/profile/client-ssl/{SSLProfile} (update profile)
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to F5 BIG-IP",
"host", c.config.Host,
"partition", c.config.Partition,
"ssl_profile", c.config.SSLProfile)
startTime := time.Now()
// TODO: Implement F5 certificate deployment
// In production:
// 1. Authenticate to F5: POST /mgmt/shared/authn/login
// 2. Create certificate object:
// POST /mgmt/tm/ltm/certificate
// Body: {"name": "certctl-cert-{timestamp}", "certificateText": "{CertPEM}"}
// 3. If chain is provided, upload as separate certificate:
// POST /mgmt/tm/ltm/certificate
// Body: {"name": "certctl-chain-{timestamp}", "certificateText": "{ChainPEM}"}
// 4. Update SSL profile:
// PATCH /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
// Body: {"certificate": "/Common/certctl-cert-{timestamp}"}
// 5. Verify deployment by checking profile status
deploymentDuration := time.Since(startTime)
c.logger.Warn("F5 deployment not yet implemented",
"host", c.config.Host,
"ssl_profile", c.config.SSLProfile)
return &target.DeploymentResult{
Success: true,
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
DeploymentID: fmt.Sprintf("f5-%d", time.Now().Unix()),
Message: "Certificate deployment to F5 initiated (stub)",
DeployedAt: time.Now(),
Metadata: map[string]string{
"host": c.config.Host,
"partition": c.config.Partition,
"ssl_profile": c.config.SSLProfile,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the certificate is properly deployed on the F5 BIG-IP.
// It checks the SSL profile configuration to ensure it references the correct certificate.
//
// TODO: Implement actual F5 validation via iControl REST API.
// API endpoint used:
// - GET /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating F5 deployment",
"certificate_id", request.CertificateID,
"serial", request.Serial,
"ssl_profile", c.config.SSLProfile)
startTime := time.Now()
// TODO: Implement F5 deployment validation
// In production:
// 1. Authenticate to F5: POST /mgmt/shared/authn/login
// 2. Query SSL profile:
// GET /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
// 3. Verify the response includes the expected certificate name
// 4. Optionally check certificate validity dates
// 5. Verify the profile is in active use (no errors/warnings)
validationDuration := time.Since(startTime)
c.logger.Warn("F5 validation not yet implemented",
"ssl_profile", c.config.SSLProfile)
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
Message: "Certificate deployment validation initiated (stub)",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"host": c.config.Host,
"ssl_profile": c.config.SSLProfile,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
}
+196
View File
@@ -0,0 +1,196 @@
package iis
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"runtime"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
)
// Config represents the IIS deployment target configuration.
// This configuration is for Windows agents that manage IIS servers.
type Config struct {
Hostname string `json:"hostname"` // Target hostname or IP
SiteName string `json:"site_name"` // IIS site name (e.g., "Default Web Site")
CertStore string `json:"cert_store"` // Windows cert store (e.g., "My", "WebHosting")
BindingInfo string `json:"binding_info"` // Binding info (e.g., "*.example.com")
}
// Connector implements the target.Connector interface for IIS (Internet Information Services).
// This connector runs on Windows agents and manages certificate deployment via IIS.
//
// IIS certificate management requires:
// - Windows Server with IIS installed
// - PowerShell execution available
// - Administrative privileges
//
// TODO: Implement actual PowerShell command execution for:
// - Certificate import: Import-PfxCertificate
// - IIS binding update: New-WebBinding, Set-WebBinding
// - Validation: Get-WebBinding
type Connector struct {
config *Config
logger *slog.Logger
}
// New creates a new IIS target connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
}
}
// ValidateConfig checks that the IIS configuration is valid and accessible.
// It verifies that we're on Windows and that the IIS site exists.
//
// TODO: Implement actual PowerShell checks.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid IIS config: %w", err)
}
if cfg.SiteName == "" || cfg.CertStore == "" {
return fmt.Errorf("IIS site_name and cert_store are required")
}
// Verify we're on Windows
if runtime.GOOS != "windows" {
return fmt.Errorf("IIS connector only runs on Windows, got %s", runtime.GOOS)
}
c.logger.Info("validating IIS configuration",
"site_name", cfg.SiteName,
"cert_store", cfg.CertStore,
"hostname", cfg.Hostname)
// TODO: Implement PowerShell check
// In production:
// 1. Run PowerShell command: Get-IISSite -Name {SiteName}
// 2. Verify site exists and is running
// 3. Check cert store: Get-Item -Path "Cert:\LocalMachine\{CertStore}"
c.logger.Warn("IIS validation not yet fully implemented",
"site_name", cfg.SiteName)
c.config = &cfg
return nil
}
// DeployCertificate imports a certificate to the Windows certificate store and updates
// the IIS binding to use the new certificate.
//
// The IIS deployment process (via PowerShell):
// 1. Create a temporary PFX file from the certificate and existing private key
// (Note: The private key is managed by the agent, not provided by the control plane)
// 2. Import the PFX to the Windows certificate store (My store by default)
// 3. Get the certificate thumbprint
// 4. Update the IIS binding to use the new certificate by thumbprint
// 5. Verify the binding is active
//
// TODO: Implement actual PowerShell commands:
// - Import-PfxCertificate -FilePath {pfxPath} -CertStoreLocation "Cert:\LocalMachine\My"
// - Get-ChildItem -Path "Cert:\LocalMachine\My" | Where {$_.Subject -eq "CN=..."}
// - Set-WebBinding -Name {SiteName} -BindingInformation "{BindingInfo}" -Protocol https -SslFlags 1 -CertificateThumbprint {thumbprint}
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to IIS",
"site_name", c.config.SiteName,
"cert_store", c.config.CertStore)
startTime := time.Now()
// TODO: Implement IIS certificate deployment
// In production:
// 1. Create temporary PFX from CertPEM and ChainPEM
// (Private key should already exist on the agent)
// 2. Import certificate:
// PowerShell: Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\LocalMachine\{CertStore}" -Password $password
// 3. Get certificate thumbprint:
// PowerShell: (Get-ChildItem -Path "Cert:\LocalMachine\{CertStore}" | Where {$_.Subject -like "*CN=*"}).Thumbprint
// 4. Update IIS binding:
// PowerShell: Set-WebBinding -Name "{SiteName}" -BindingInformation "{BindingInfo}:443:*.example.com" -Protocol https -CertificateThumbprint $thumbprint
// 5. Remove temporary PFX file
deploymentDuration := time.Since(startTime)
c.logger.Warn("IIS deployment not yet implemented",
"site_name", c.config.SiteName)
return &target.DeploymentResult{
Success: true,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
DeploymentID: fmt.Sprintf("iis-%d", time.Now().Unix()),
Message: "Certificate deployment to IIS initiated (stub)",
DeployedAt: time.Now(),
Metadata: map[string]string{
"hostname": c.config.Hostname,
"site_name": c.config.SiteName,
"cert_store": c.config.CertStore,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the certificate is properly deployed in IIS.
// It checks the IIS binding configuration to ensure it's active with the correct certificate.
//
// TODO: Implement actual PowerShell validation.
// PowerShell command:
// - Get-IISSiteBinding -Name {SiteName} | Where {$_.protocol -eq "https"}
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating IIS deployment",
"certificate_id", request.CertificateID,
"serial", request.Serial,
"site_name", c.config.SiteName)
startTime := time.Now()
// TODO: Implement IIS deployment validation
// In production:
// 1. Query IIS binding status:
// PowerShell: Get-WebBinding -Name "{SiteName}" -Protocol "https"
// 2. Verify binding exists and is active
// 3. Extract certificate thumbprint from binding
// 4. Query certificate store to verify thumbprint matches expected certificate
// 5. Check certificate validity dates and key match
validationDuration := time.Since(startTime)
c.logger.Warn("IIS validation not yet implemented",
"site_name", c.config.SiteName)
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
Message: "Certificate deployment validation initiated (stub)",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"hostname": c.config.Hostname,
"site_name": c.config.SiteName,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
}
// executePowerShellCommand is a helper to run PowerShell commands on Windows.
// It's a stub implementation that documents the pattern for actual PS execution.
func (c *Connector) executePowerShellCommand(ctx context.Context, psCommand string) (string, error) {
if runtime.GOOS != "windows" {
return "", fmt.Errorf("PowerShell commands only work on Windows")
}
// TODO: Implement actual PowerShell execution
// In production:
// cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCommand)
// output, err := cmd.CombinedOutput()
// return string(output), err
c.logger.Debug("executing PowerShell command", "command", psCommand)
return "", nil
}
+57
View File
@@ -0,0 +1,57 @@
package target
import (
"context"
"encoding/json"
"time"
)
// Connector defines the interface for certificate deployment operations.
type Connector interface {
// ValidateConfig validates the deployment target configuration.
ValidateConfig(ctx context.Context, config json.RawMessage) error
// DeployCertificate deploys a certificate to the target.
// The request contains the certificate and chain in PEM format, but never a private key.
DeployCertificate(ctx context.Context, request DeploymentRequest) (*DeploymentResult, error)
// ValidateDeployment verifies that a deployed certificate is valid and accessible.
ValidateDeployment(ctx context.Context, request ValidationRequest) (*ValidationResult, error)
}
// DeploymentRequest contains the parameters for deploying a certificate to a target.
// Note: This request NEVER contains a private key. The agent generates keys locally.
type DeploymentRequest struct {
CertPEM string `json:"cert_pem"`
ChainPEM string `json:"chain_pem"`
TargetConfig json.RawMessage `json:"target_config"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// DeploymentResult contains the result of a successful certificate deployment.
type DeploymentResult struct {
Success bool `json:"success"`
TargetAddress string `json:"target_address"`
DeploymentID string `json:"deployment_id"`
Message string `json:"message"`
DeployedAt time.Time `json:"deployed_at"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// ValidationRequest contains the parameters for validating a deployed certificate.
type ValidationRequest struct {
CertificateID string `json:"certificate_id"`
Serial string `json:"serial"`
TargetConfig json.RawMessage `json:"target_config"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// ValidationResult contains the result of a certificate validation check.
type ValidationResult struct {
Valid bool `json:"valid"`
Serial string `json:"serial"`
TargetAddress string `json:"target_address"`
Message string `json:"message"`
ValidatedAt time.Time `json:"validated_at"`
Metadata map[string]string `json:"metadata,omitempty"`
}
+222
View File
@@ -0,0 +1,222 @@
package nginx
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
)
// Config represents the NGINX deployment target configuration.
// This configuration is used on the agent side to deploy certificates to NGINX.
type Config struct {
CertPath string `json:"cert_path"` // Path where cert will be written (typically /etc/nginx/certs/cert.pem)
KeyPath string `json:"key_path"` // Path where private key will be written (NOT provided by control plane)
ChainPath string `json:"chain_path"` // Path where chain will be written (typically /etc/nginx/certs/chain.pem)
ReloadCommand string `json:"reload_command"` // Command to reload NGINX (e.g., "nginx -s reload" or "systemctl reload nginx")
ValidateCommand string `json:"validate_command"` // Command to validate NGINX config (e.g., "nginx -t")
}
// Connector implements the target.Connector interface for NGINX servers.
// This connector runs on the AGENT side and handles local certificate deployment.
type Connector struct {
config *Config
logger *slog.Logger
}
// New creates a new NGINX target connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
}
}
// ValidateConfig checks that all required configuration paths and commands are valid.
// It verifies that the certificate and key paths are writable and commands are executable.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid NGINX config: %w", err)
}
if cfg.CertPath == "" || cfg.ChainPath == "" {
return fmt.Errorf("NGINX cert_path and chain_path are required")
}
if cfg.ReloadCommand == "" || cfg.ValidateCommand == "" {
return fmt.Errorf("NGINX reload_command and validate_command are required")
}
c.logger.Info("validating NGINX configuration",
"cert_path", cfg.CertPath,
"chain_path", cfg.ChainPath)
// Verify directory exists and is writable
certDir := cfg.CertPath[:len(cfg.CertPath)-len("/cert.pem")] // Simple path extraction
if _, err := os.Stat(certDir); os.IsNotExist(err) {
return fmt.Errorf("NGINX cert directory does not exist: %s", certDir)
}
// Verify validate command works
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
if err := cmd.Run(); err != nil {
c.logger.Warn("NGINX config validation failed during config check",
"error", err,
"validate_command", cfg.ValidateCommand)
// Don't fail validation; NGINX might not be installed yet
}
c.config = &cfg
c.logger.Info("NGINX configuration validated")
return nil
}
// DeployCertificate writes the certificate and chain to the configured paths
// and reloads NGINX to pick up the new certificates.
// The agent (not the control plane) manages the private key.
//
// Steps:
// 1. Write certificate to cert_path with mode 0644 (readable by all)
// 2. Write chain to chain_path with mode 0644
// 3. Validate NGINX configuration
// 4. Execute reload command
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to NGINX",
"cert_path", c.config.CertPath,
"chain_path", c.config.ChainPath)
startTime := time.Now()
// Write certificate with secure permissions (0644: rw-r--r--)
if err := os.WriteFile(c.config.CertPath, []byte(request.CertPEM), 0644); err != nil {
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
c.logger.Error("certificate deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.CertPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
// Write chain with same permissions
if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil {
errMsg := fmt.Sprintf("failed to write chain: %v", err)
c.logger.Error("chain deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.ChainPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
// Validate NGINX configuration before reload
c.logger.Debug("validating NGINX configuration", "validate_command", c.config.ValidateCommand)
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
if err := validateCmd.Run(); err != nil {
errMsg := fmt.Sprintf("NGINX config validation failed: %v", err)
c.logger.Error("NGINX validation failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.CertPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
// Reload NGINX
c.logger.Debug("reloading NGINX", "reload_command", c.config.ReloadCommand)
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
if err := reloadCmd.Run(); err != nil {
errMsg := fmt.Sprintf("NGINX reload failed: %v", err)
c.logger.Error("NGINX reload failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: c.config.CertPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
deploymentDuration := time.Since(startTime)
c.logger.Info("certificate deployed to NGINX successfully",
"duration", deploymentDuration.String(),
"cert_path", c.config.CertPath)
return &target.DeploymentResult{
Success: true,
TargetAddress: c.config.CertPath,
DeploymentID: fmt.Sprintf("nginx-%d", time.Now().Unix()),
Message: "Certificate deployed and NGINX reloaded successfully",
DeployedAt: time.Now(),
Metadata: map[string]string{
"cert_path": c.config.CertPath,
"chain_path": c.config.ChainPath,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
// It validates the NGINX configuration to ensure the certificate can be read.
//
// Steps:
// 1. Run validate command to check config syntax
// 2. Verify certificate file is readable
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating NGINX deployment",
"certificate_id", request.CertificateID,
"serial", request.Serial)
startTime := time.Now()
// Validate NGINX configuration
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
if err := validateCmd.Run(); err != nil {
errMsg := fmt.Sprintf("NGINX config validation failed: %v", err)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: c.config.CertPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
// Verify certificate file exists and is readable
if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) {
errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: c.config.CertPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf(errMsg)
}
validationDuration := time.Since(startTime)
c.logger.Info("NGINX deployment validated successfully",
"duration", validationDuration.String())
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: c.config.CertPath,
Message: "NGINX configuration valid and certificate accessible",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"validate_command": c.config.ValidateCommand,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
}
+27
View File
@@ -0,0 +1,27 @@
package domain
import (
"encoding/json"
"time"
)
// AuditEvent records an action taken in the control plane.
type AuditEvent struct {
ID string `json:"id"`
Actor string `json:"actor"`
ActorType ActorType `json:"actor_type"`
Action string `json:"action"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
Details json.RawMessage `json:"details"`
Timestamp time.Time `json:"timestamp"`
}
// ActorType represents the entity performing an action.
type ActorType string
const (
ActorTypeUser ActorType = "User"
ActorTypeSystem ActorType = "System"
ActorTypeAgent ActorType = "Agent"
)
+65
View File
@@ -0,0 +1,65 @@
package domain
import (
"time"
)
// ManagedCertificate represents a certificate managed by the control plane.
type ManagedCertificate struct {
ID string `json:"id"`
Name string `json:"name"`
CommonName string `json:"common_name"`
SANs []string `json:"sans"`
Environment string `json:"environment"`
OwnerID string `json:"owner_id"`
TeamID string `json:"team_id"`
IssuerID string `json:"issuer_id"`
TargetIDs []string `json:"target_ids"`
RenewalPolicyID string `json:"renewal_policy_id"`
Status CertificateStatus `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
Tags map[string]string `json:"tags"`
LastRenewalAt *time.Time `json:"last_renewal_at,omitempty"`
LastDeploymentAt *time.Time `json:"last_deployment_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CertificateVersion represents a specific version of a certificate.
type CertificateVersion struct {
ID string `json:"id"`
CertificateID string `json:"certificate_id"`
SerialNumber string `json:"serial_number"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
FingerprintSHA256 string `json:"fingerprint_sha256"`
PEMChain string `json:"pem_chain"`
CSRPEM string `json:"csr_pem"`
CreatedAt time.Time `json:"created_at"`
}
// CertificateStatus represents the lifecycle status of a managed certificate.
type CertificateStatus string
const (
CertificateStatusPending CertificateStatus = "Pending"
CertificateStatusActive CertificateStatus = "Active"
CertificateStatusExpiring CertificateStatus = "Expiring"
CertificateStatusExpired CertificateStatus = "Expired"
CertificateStatusRenewalInProgress CertificateStatus = "RenewalInProgress"
CertificateStatusFailed CertificateStatus = "Failed"
CertificateStatusRevoked CertificateStatus = "Revoked"
CertificateStatusArchived CertificateStatus = "Archived"
)
// RenewalPolicy defines renewal parameters for a managed certificate.
type RenewalPolicy struct {
ID string `json:"id"`
Name string `json:"name"`
RenewalWindowDays int `json:"renewal_window_days"`
AutoRenew bool `json:"auto_renew"`
MaxRetries int `json:"max_retries"`
RetryInterval int `json:"retry_interval_seconds"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+66
View File
@@ -0,0 +1,66 @@
package domain
import (
"encoding/json"
"time"
)
// Issuer represents a certificate authority or ACME provider.
type Issuer struct {
ID string `json:"id"`
Name string `json:"name"`
Type IssuerType `json:"type"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// DeploymentTarget represents a target system where certificates are deployed.
type DeploymentTarget struct {
ID string `json:"id"`
Name string `json:"name"`
Type TargetType `json:"type"`
AgentID string `json:"agent_id"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Agent represents an agent running on a target system.
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Status AgentStatus `json:"status"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
RegisteredAt time.Time `json:"registered_at"`
APIKeyHash string `json:"api_key_hash"`
}
// AgentStatus represents the operational status of an agent.
type AgentStatus string
const (
AgentStatusOnline AgentStatus = "Online"
AgentStatusOffline AgentStatus = "Offline"
AgentStatusDegraded AgentStatus = "Degraded"
)
// IssuerType represents the type of certificate authority.
type IssuerType string
const (
IssuerTypeACME IssuerType = "ACME"
IssuerTypeGenericCA IssuerType = "GenericCA"
)
// TargetType represents the type of deployment target.
type TargetType string
const (
TargetTypeNGINX TargetType = "NGINX"
TargetTypeF5 TargetType = "F5"
TargetTypeIIS TargetType = "IIS"
)
+50
View File
@@ -0,0 +1,50 @@
package domain
import (
"encoding/json"
"time"
)
// Job represents a unit of work in the certificate control plane.
type Job struct {
ID string `json:"id"`
Type JobType `json:"type"`
CertificateID string `json:"certificate_id"`
TargetID *string `json:"target_id,omitempty"`
Status JobStatus `json:"status"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
LastError *string `json:"last_error,omitempty"`
ScheduledAt time.Time `json:"scheduled_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// JobType represents the classification of work to be performed.
type JobType string
const (
JobTypeIssuance JobType = "Issuance"
JobTypeRenewal JobType = "Renewal"
JobTypeDeployment JobType = "Deployment"
JobTypeValidation JobType = "Validation"
)
// JobStatus represents the execution state of a job.
type JobStatus string
const (
JobStatusPending JobStatus = "Pending"
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.
type DeploymentJob struct {
Job `json:"job"`
AgentID string `json:"agent_id"`
DeploymentResult json.RawMessage `json:"deployment_result,omitempty"`
}
+40
View File
@@ -0,0 +1,40 @@
package domain
import (
"time"
)
// NotificationEvent records a notification sent to users about certificate events.
type NotificationEvent struct {
ID string `json:"id"`
Type NotificationType `json:"type"`
CertificateID *string `json:"certificate_id,omitempty"`
Channel NotificationChannel `json:"channel"`
Recipient string `json:"recipient"`
Message string `json:"message"`
SentAt *time.Time `json:"sent_at,omitempty"`
Status string `json:"status"`
Error *string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// NotificationType represents the event that triggered a notification.
type NotificationType string
const (
NotificationTypeExpirationWarning NotificationType = "ExpirationWarning"
NotificationTypeRenewalSuccess NotificationType = "RenewalSuccess"
NotificationTypeRenewalFailure NotificationType = "RenewalFailure"
NotificationTypeDeploymentSuccess NotificationType = "DeploymentSuccess"
NotificationTypeDeploymentFailure NotificationType = "DeploymentFailure"
NotificationTypePolicyViolation NotificationType = "PolicyViolation"
)
// NotificationChannel represents the communication medium for a notification.
type NotificationChannel string
const (
NotificationChannelEmail NotificationChannel = "Email"
NotificationChannelWebhook NotificationChannel = "Webhook"
NotificationChannelSlack NotificationChannel = "Slack"
)
+47
View File
@@ -0,0 +1,47 @@
package domain
import (
"encoding/json"
"time"
)
// PolicyRule defines enforcement rules for certificate management.
type PolicyRule struct {
ID string `json:"id"`
Name string `json:"name"`
Type PolicyType `json:"type"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PolicyType represents the category of policy enforcement.
type PolicyType string
const (
PolicyTypeAllowedIssuers PolicyType = "AllowedIssuers"
PolicyTypeAllowedDomains PolicyType = "AllowedDomains"
PolicyTypeRequiredMetadata PolicyType = "RequiredMetadata"
PolicyTypeAllowedEnvironments PolicyType = "AllowedEnvironments"
PolicyTypeRenewalLeadTime PolicyType = "RenewalLeadTime"
)
// PolicyViolation records an instance of a certificate violating a policy rule.
type PolicyViolation struct {
ID string `json:"id"`
CertificateID string `json:"certificate_id"`
RuleID string `json:"rule_id"`
Message string `json:"message"`
Severity PolicySeverity `json:"severity"`
CreatedAt time.Time `json:"created_at"`
}
// PolicySeverity indicates the impact level of a policy violation.
type PolicySeverity string
const (
PolicySeverityWarning PolicySeverity = "Warning"
PolicySeverityError PolicySeverity = "Error"
PolicySeverityCritical PolicySeverity = "Critical"
)
+24
View File
@@ -0,0 +1,24 @@
package domain
import (
"time"
)
// Team represents an organizational unit managing certificates.
type Team struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Owner represents a user who owns certificates within a team.
type Owner struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
TeamID string `json:"team_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+44
View File
@@ -0,0 +1,44 @@
package repository
import "time"
// CertificateFilter defines filtering criteria for certificate queries.
type CertificateFilter struct {
Status string // e.g., "active", "expiring", "expired", "archived"
Environment string // e.g., "production", "staging", "development"
OwnerID string
TeamID string
IssuerID string
Page int // 1-indexed; default 1
PerPage int // default 50, max 500
}
// JobFilter defines filtering criteria for job queries.
type JobFilter struct {
Status string // e.g., "pending", "in-progress", "completed", "failed"
Type string // e.g., "renewal", "deployment"
CertificateID string
Page int
PerPage int
}
// AuditFilter defines filtering criteria for audit event queries.
type AuditFilter struct {
Actor string // username or service ID
ActorType string // "user", "agent", "system"
ResourceType string // e.g., "certificate", "policy", "agent"
ResourceID string
From time.Time
To time.Time
Page int
PerPage int
}
// NotificationFilter defines filtering criteria for notification queries.
type NotificationFilter struct {
CertificateID string // optional: filter by certificate
Status string // e.g., "pending", "sent", "failed"
Channel string // e.g., "email", "slack", "webhook"
Page int
PerPage int
}
+162
View File
@@ -0,0 +1,162 @@
package repository
import (
"context"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// CertificateRepository defines operations for managing certificates.
type CertificateRepository interface {
// List returns a paginated list of certificates matching the filter criteria.
List(ctx context.Context, filter *CertificateFilter) ([]*domain.ManagedCertificate, int, error)
// Get retrieves a certificate by ID.
Get(ctx context.Context, id string) (*domain.ManagedCertificate, error)
// Create stores a new certificate.
Create(ctx context.Context, cert *domain.ManagedCertificate) error
// Update modifies an existing certificate.
Update(ctx context.Context, cert *domain.ManagedCertificate) error
// Archive marks a certificate as archived.
Archive(ctx context.Context, id string) error
// ListVersions returns all versions of a certificate.
ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error)
// CreateVersion stores a new certificate version.
CreateVersion(ctx context.Context, version *domain.CertificateVersion) error
// GetExpiringCertificates returns certificates expiring before the given time.
GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error)
}
// IssuerRepository defines operations for managing certificate issuers.
type IssuerRepository interface {
// List returns all issuers, optionally filtered.
List(ctx context.Context) ([]*domain.Issuer, error)
// Get retrieves an issuer by ID.
Get(ctx context.Context, id string) (*domain.Issuer, error)
// Create stores a new issuer.
Create(ctx context.Context, issuer *domain.Issuer) error
// Update modifies an existing issuer.
Update(ctx context.Context, issuer *domain.Issuer) error
// Delete removes an issuer.
Delete(ctx context.Context, id string) error
}
// TargetRepository defines operations for managing deployment targets.
type TargetRepository interface {
// List returns all targets, optionally filtered.
List(ctx context.Context) ([]*domain.DeploymentTarget, error)
// Get retrieves a target by ID.
Get(ctx context.Context, id string) (*domain.DeploymentTarget, error)
// Create stores a new target.
Create(ctx context.Context, target *domain.DeploymentTarget) error
// Update modifies an existing target.
Update(ctx context.Context, target *domain.DeploymentTarget) error
// Delete removes a target.
Delete(ctx context.Context, id string) error
// ListByCertificate returns all targets for a given certificate.
ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error)
}
// AgentRepository defines operations for managing control plane agents.
type AgentRepository interface {
// List returns all agents.
List(ctx context.Context) ([]*domain.Agent, error)
// Get retrieves an agent by ID.
Get(ctx context.Context, id string) (*domain.Agent, error)
// Create stores a new agent.
Create(ctx context.Context, agent *domain.Agent) error
// Update modifies an existing agent.
Update(ctx context.Context, agent *domain.Agent) error
// Delete removes an agent.
Delete(ctx context.Context, id string) error
// UpdateHeartbeat updates the agent's last heartbeat timestamp.
UpdateHeartbeat(ctx context.Context, id string) error
// GetByAPIKey retrieves an agent by hashed API key.
GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error)
}
// JobRepository defines operations for managing renewal and deployment jobs.
type JobRepository interface {
// List returns all jobs.
List(ctx context.Context) ([]*domain.Job, error)
// Get retrieves a job by ID.
Get(ctx context.Context, id string) (*domain.Job, error)
// Create stores a new job.
Create(ctx context.Context, job *domain.Job) error
// Update modifies an existing job.
Update(ctx context.Context, job *domain.Job) error
// Delete removes a job.
Delete(ctx context.Context, id string) error
// ListByStatus returns jobs with a specific status.
ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error)
// ListByCertificate returns all jobs for a certificate.
ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error)
// UpdateStatus updates a job's status and optional error message.
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
// GetPendingJobs returns jobs not yet processed of a specific type.
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
}
// PolicyRepository defines operations for managing compliance policies and violations.
type PolicyRepository interface {
// ListRules returns all policy rules.
ListRules(ctx context.Context) ([]*domain.PolicyRule, error)
// GetRule retrieves a policy rule by ID.
GetRule(ctx context.Context, id string) (*domain.PolicyRule, error)
// CreateRule stores a new policy rule.
CreateRule(ctx context.Context, rule *domain.PolicyRule) error
// UpdateRule modifies an existing policy rule.
UpdateRule(ctx context.Context, rule *domain.PolicyRule) error
// DeleteRule removes a policy rule.
DeleteRule(ctx context.Context, id string) error
// CreateViolation records a policy violation.
CreateViolation(ctx context.Context, violation *domain.PolicyViolation) error
// ListViolations returns policy violations, optionally filtered.
ListViolations(ctx context.Context, filter *AuditFilter) ([]*domain.PolicyViolation, error)
}
// AuditRepository defines operations for recording and retrieving audit logs.
type AuditRepository interface {
// Create stores a new audit event.
Create(ctx context.Context, event *domain.AuditEvent) error
// List returns audit events matching the filter criteria.
List(ctx context.Context, filter *AuditFilter) ([]*domain.AuditEvent, error)
}
// NotificationRepository defines operations for managing notifications.
type NotificationRepository interface {
// Create stores a new notification.
Create(ctx context.Context, notif *domain.NotificationEvent) error
// List returns notifications matching the filter criteria.
List(ctx context.Context, filter *NotificationFilter) ([]*domain.NotificationEvent, error)
// UpdateStatus updates a notification's delivery status.
UpdateStatus(ctx context.Context, id string, status string, sentAt time.Time) error
}
// TeamRepository defines operations for managing teams.
type TeamRepository interface {
// List returns all teams.
List(ctx context.Context) ([]*domain.Team, error)
// Get retrieves a team by ID.
Get(ctx context.Context, id string) (*domain.Team, error)
// Create stores a new team.
Create(ctx context.Context, team *domain.Team) error
// Update modifies an existing team.
Update(ctx context.Context, team *domain.Team) error
// Delete removes a team.
Delete(ctx context.Context, id string) error
}
// OwnerRepository defines operations for managing certificate owners.
type OwnerRepository interface {
// List returns all owners.
List(ctx context.Context) ([]*domain.Owner, error)
// Get retrieves an owner by ID.
Get(ctx context.Context, id string) (*domain.Owner, error)
// Create stores a new owner.
Create(ctx context.Context, owner *domain.Owner) error
// Update modifies an existing owner.
Update(ctx context.Context, owner *domain.Owner) error
// Delete removes an owner.
Delete(ctx context.Context, id string) error
}
+219
View File
@@ -0,0 +1,219 @@
package scheduler
import (
"context"
"log/slog"
"time"
"github.com/shankar0123/certctl/internal/service"
)
// Scheduler manages background jobs and periodic tasks for the certificate control plane.
// It runs multiple concurrent loops for renewal checks, job processing, agent health checks,
// and notification processing.
type Scheduler struct {
renewalService *service.RenewalService
jobService *service.JobService
agentService *service.AgentService
notificationService *service.NotificationService
logger *slog.Logger
// Configurable tick intervals
renewalCheckInterval time.Duration
jobProcessorInterval time.Duration
agentHealthCheckInterval time.Duration
notificationProcessInterval time.Duration
}
// NewScheduler creates a new scheduler with configurable intervals.
func NewScheduler(
renewalService *service.RenewalService,
jobService *service.JobService,
agentService *service.AgentService,
notificationService *service.NotificationService,
logger *slog.Logger,
) *Scheduler {
return &Scheduler{
renewalService: renewalService,
jobService: jobService,
agentService: agentService,
notificationService: notificationService,
logger: logger,
// Default intervals
renewalCheckInterval: 1 * time.Hour,
jobProcessorInterval: 30 * time.Second,
agentHealthCheckInterval: 2 * time.Minute,
notificationProcessInterval: 1 * time.Minute,
}
}
// SetRenewalCheckInterval configures the interval for renewal checks.
func (s *Scheduler) SetRenewalCheckInterval(d time.Duration) {
s.renewalCheckInterval = d
}
// SetJobProcessorInterval configures the interval for job processing.
func (s *Scheduler) SetJobProcessorInterval(d time.Duration) {
s.jobProcessorInterval = d
}
// SetAgentHealthCheckInterval configures the interval for agent health checks.
func (s *Scheduler) SetAgentHealthCheckInterval(d time.Duration) {
s.agentHealthCheckInterval = d
}
// SetNotificationProcessInterval configures the interval for notification processing.
func (s *Scheduler) SetNotificationProcessInterval(d time.Duration) {
s.notificationProcessInterval = d
}
// Start initiates all background scheduler loops. It returns a channel that signals
// when the scheduler has started all loops. The scheduler runs until the context is cancelled.
func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
startedChan := make(chan struct{})
go func() {
s.logger.Info("scheduler starting")
// Signal that the scheduler has started all loops
go func() {
<-time.After(100 * time.Millisecond)
close(startedChan)
}()
// Start all scheduler loops concurrently
go s.renewalCheckLoop(ctx)
go s.jobProcessorLoop(ctx)
go s.agentHealthCheckLoop(ctx)
go s.notificationProcessLoop(ctx)
// Wait for context cancellation
<-ctx.Done()
s.logger.Info("scheduler shutting down", "reason", ctx.Err())
}()
return startedChan
}
// renewalCheckLoop runs every renewalCheckInterval and checks for expiring certificates.
// If an error occurs, it logs the error but continues running.
func (s *Scheduler) renewalCheckLoop(ctx context.Context) {
ticker := time.NewTicker(s.renewalCheckInterval)
defer ticker.Stop()
// Run immediately on start
s.runRenewalCheck(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runRenewalCheck(ctx)
}
}
}
// runRenewalCheck executes a single renewal check with error recovery.
func (s *Scheduler) runRenewalCheck(ctx context.Context) {
if err := s.renewalService.CheckExpiringCertificates(ctx); err != nil {
s.logger.Error("renewal check failed",
"error", err,
"interval", s.renewalCheckInterval.String())
} else {
s.logger.Debug("renewal check completed")
}
}
// jobProcessorLoop runs every jobProcessorInterval and processes pending jobs.
// It picks up pending jobs, executes them, and handles the results.
// If an error occurs, it logs the error but continues running.
func (s *Scheduler) jobProcessorLoop(ctx context.Context) {
ticker := time.NewTicker(s.jobProcessorInterval)
defer ticker.Stop()
// Run immediately on start
s.runJobProcessor(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runJobProcessor(ctx)
}
}
}
// runJobProcessor executes a single job processing cycle with error recovery.
func (s *Scheduler) runJobProcessor(ctx context.Context) {
if err := s.jobService.ProcessPendingJobs(ctx); err != nil {
s.logger.Error("job processor failed",
"error", err,
"interval", s.jobProcessorInterval.String())
} else {
s.logger.Debug("job processor completed")
}
}
// agentHealthCheckLoop runs every agentHealthCheckInterval and marks stale agents as offline.
// An agent is considered stale if it hasn't sent a heartbeat within the health check interval.
// If an error occurs, it logs the error but continues running.
func (s *Scheduler) agentHealthCheckLoop(ctx context.Context) {
ticker := time.NewTicker(s.agentHealthCheckInterval)
defer ticker.Stop()
// Run immediately on start
s.runAgentHealthCheck(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runAgentHealthCheck(ctx)
}
}
}
// runAgentHealthCheck executes a single agent health check with error recovery.
func (s *Scheduler) runAgentHealthCheck(ctx context.Context) {
if err := s.agentService.MarkStaleAgentsOffline(ctx, s.agentHealthCheckInterval); err != nil {
s.logger.Error("agent health check failed",
"error", err,
"interval", s.agentHealthCheckInterval.String())
} else {
s.logger.Debug("agent health check completed")
}
}
// notificationProcessLoop runs every notificationProcessInterval and processes pending notifications.
// If an error occurs, it logs the error but continues running.
func (s *Scheduler) notificationProcessLoop(ctx context.Context) {
ticker := time.NewTicker(s.notificationProcessInterval)
defer ticker.Stop()
// Run immediately on start
s.runNotificationProcess(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runNotificationProcess(ctx)
}
}
}
// runNotificationProcess executes a single notification processing cycle with error recovery.
func (s *Scheduler) runNotificationProcess(ctx context.Context) {
if err := s.notificationService.ProcessPendingNotifications(ctx); err != nil {
s.logger.Error("notification processor failed",
"error", err,
"interval", s.notificationProcessInterval.String())
} else {
s.logger.Debug("notification processor completed")
}
}
+261
View File
@@ -0,0 +1,261 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/rand"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// AgentService provides business logic for managing and coordinating with agents.
type AgentService struct {
agentRepo repository.AgentRepository
certRepo repository.CertificateRepository
jobRepo repository.JobRepository
auditService *AuditService
issuerRegistry map[string]IssuerConnector
}
// NewAgentService creates a new agent service.
func NewAgentService(
agentRepo repository.AgentRepository,
certRepo repository.CertificateRepository,
jobRepo repository.JobRepository,
auditService *AuditService,
issuerRegistry map[string]IssuerConnector,
) *AgentService {
return &AgentService{
agentRepo: agentRepo,
certRepo: certRepo,
jobRepo: jobRepo,
auditService: auditService,
issuerRegistry: issuerRegistry,
}
}
// Register creates a new agent and returns its API key (only once).
func (s *AgentService) Register(ctx context.Context, name string, hostname string) (*domain.Agent, string, error) {
if name == "" || hostname == "" {
return nil, "", fmt.Errorf("agent name and hostname are required")
}
// Generate API key
apiKey := generateAPIKey()
apiKeyHash := hashAPIKey(apiKey)
now := time.Now()
agent := &domain.Agent{
ID: generateID("agent"),
Name: name,
Hostname: hostname,
APIKeyHash: apiKeyHash,
Status: domain.AgentStatusOnline,
RegisteredAt: now,
LastHeartbeatAt: &now,
}
if err := s.agentRepo.Create(ctx, agent); err != nil {
return nil, "", fmt.Errorf("failed to create agent: %w", err)
}
// Record audit event
if err := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"agent_registered", "agent", agent.ID,
map[string]interface{}{"name": name, "hostname": hostname}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
// Return the API key only once; the agent must save it securely
return agent, apiKey, nil
}
// Heartbeat updates an agent's last seen time and status.
func (s *AgentService) Heartbeat(ctx context.Context, agentID string) error {
agent, err := s.agentRepo.Get(ctx, agentID)
if err != nil {
return fmt.Errorf("failed to fetch agent: %w", err)
}
// Update heartbeat
if err := s.agentRepo.UpdateHeartbeat(ctx, agentID); err != nil {
return fmt.Errorf("failed to update heartbeat: %w", err)
}
// Update status if previously offline
if agent.Status != domain.AgentStatusOnline {
agent.Status = domain.AgentStatusOnline
if err := s.agentRepo.Update(ctx, agent); err != nil {
fmt.Printf("failed to update agent status: %v\n", err)
}
}
return nil
}
// SubmitCSR validates and processes a Certificate Signing Request from an agent.
func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID string, csrPEM []byte) error {
// Fetch agent
agent, err := s.agentRepo.Get(ctx, agentID)
if err != nil {
return fmt.Errorf("failed to fetch agent: %w", err)
}
// Validate CSR format (basic check)
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
// In a production system, we'd store the CSR in a certificate version or metadata
// For now, we just validate and accept it
// Record audit event
if err := 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)
}
return nil
}
// GetCertificateForAgent returns the latest public certificate material for an agent.
func (s *AgentService) GetCertificateForAgent(ctx context.Context, agentID string, certID string) ([]byte, error) {
// Fetch agent
_, err := s.agentRepo.Get(ctx, agentID)
if err != nil {
return nil, fmt.Errorf("failed to fetch agent: %w", err)
}
// Get latest version
versions, err := s.certRepo.ListVersions(ctx, certID)
if err != nil {
return nil, fmt.Errorf("failed to fetch certificate versions: %w", err)
}
if len(versions) == 0 {
return nil, fmt.Errorf("no certificate versions found")
}
// Return the most recent version (latest CreatedAt timestamp)
latestVersion := versions[0]
for _, v := range versions {
if v.CreatedAt.After(latestVersion.CreatedAt) {
latestVersion = v
}
}
// Record audit event
if err := s.auditService.RecordEvent(ctx, agentID, domain.ActorTypeAgent,
"certificate_retrieved", "certificate", certID,
map[string]interface{}{"version": latestVersion.SerialNumber}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return []byte(latestVersion.PEMChain), nil
}
// GetPendingWork returns deployment jobs assigned to an agent.
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
// Fetch agent to verify it exists
_, err := s.agentRepo.Get(ctx, agentID)
if err != nil {
return nil, fmt.Errorf("failed to fetch agent: %w", err)
}
// Get all deployment jobs
jobs, 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 {
if job.Type == domain.JobTypeDeployment {
workForAgent = append(workForAgent, job)
}
}
return workForAgent, nil
}
// ReportJobStatus updates a job's status based on agent feedback.
func (s *AgentService) ReportJobStatus(ctx context.Context, agentID string, jobID string, status domain.JobStatus, errMsg string) error {
// Fetch job to verify it exists
_, err := s.jobRepo.Get(ctx, jobID)
if err != nil {
return fmt.Errorf("failed to fetch job: %w", err)
}
// Update job status
if err := s.jobRepo.UpdateStatus(ctx, jobID, status, errMsg); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Record audit event
if err := s.auditService.RecordEvent(ctx, agentID, domain.ActorTypeAgent,
"job_status_reported", "job", jobID,
map[string]interface{}{"status": status, "error": errMsg}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// MarkStaleAgentsOffline marks agents as offline if they haven't sent a heartbeat
// within the given threshold duration.
func (s *AgentService) MarkStaleAgentsOffline(ctx context.Context, threshold time.Duration) error {
agents, err := s.agentRepo.List(ctx)
if err != nil {
return fmt.Errorf("failed to list agents: %w", err)
}
cutoff := time.Now().Add(-threshold)
for _, agent := range agents {
if agent.Status == domain.AgentStatusOnline && agent.LastHeartbeatAt != nil && agent.LastHeartbeatAt.Before(cutoff) {
agent.Status = domain.AgentStatusOffline
if err := s.agentRepo.Update(ctx, agent); err != nil {
fmt.Printf("failed to mark agent %s offline: %v\n", agent.ID, err)
continue
}
}
}
return nil
}
// GetAgentByAPIKey retrieves an agent by hashed API key.
func (s *AgentService) GetAgentByAPIKey(ctx context.Context, apiKey string) (*domain.Agent, error) {
apiKeyHash := hashAPIKey(apiKey)
agent, err := s.agentRepo.GetByAPIKey(ctx, apiKeyHash)
if err != nil {
return nil, fmt.Errorf("invalid API key: %w", err)
}
return agent, nil
}
// generateAPIKey creates a random API key for an agent.
func generateAPIKey() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 32)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
// hashAPIKey hashes an API key using SHA256.
func hashAPIKey(apiKey string) string {
hash := sha256.Sum256([]byte(apiKey))
return hex.EncodeToString(hash[:])
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// AuditService provides business logic for recording and retrieving audit events.
type AuditService struct {
auditRepo repository.AuditRepository
}
// NewAuditService creates a new audit service.
func NewAuditService(auditRepo repository.AuditRepository) *AuditService {
return &AuditService{
auditRepo: auditRepo,
}
}
// RecordEvent records an audit event with actor, action, and resource information.
func (s *AuditService) RecordEvent(ctx context.Context, actor string, actorType domain.ActorType, action string, resourceType string, resourceID string, details map[string]interface{}) error {
detailsJSON, err := json.Marshal(details)
if err != nil {
detailsJSON = []byte("{}")
}
event := &domain.AuditEvent{
ID: generateID("audit"),
Timestamp: time.Now(),
Actor: actor,
ActorType: actorType,
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
Details: json.RawMessage(detailsJSON),
}
if err := s.auditRepo.Create(ctx, event); err != nil {
return fmt.Errorf("failed to record audit event: %w", err)
}
return nil
}
// List returns audit events matching filter criteria.
func (s *AuditService) List(ctx context.Context, filter *repository.AuditFilter) ([]*domain.AuditEvent, error) {
events, err := s.auditRepo.List(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list audit events: %w", err)
}
return events, nil
}
// ListByResource returns all audit events for a specific resource.
func (s *AuditService) ListByResource(ctx context.Context, resourceType string, resourceID string) ([]*domain.AuditEvent, error) {
filter := &repository.AuditFilter{
ResourceType: resourceType,
ResourceID: resourceID,
PerPage: 1000, // reasonable default for single resource
}
events, err := s.auditRepo.List(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list audit events: %w", err)
}
return events, nil
}
// ListByActor returns all audit events for a specific actor.
func (s *AuditService) ListByActor(ctx context.Context, actor string) ([]*domain.AuditEvent, error) {
filter := &repository.AuditFilter{
Actor: actor,
PerPage: 1000,
}
events, err := s.auditRepo.List(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list audit events: %w", err)
}
return events, nil
}
// ListByAction returns all audit events for a specific action type.
func (s *AuditService) ListByAction(ctx context.Context, action string, from, to time.Time) ([]*domain.AuditEvent, error) {
filter := &repository.AuditFilter{
From: from,
To: to,
PerPage: 1000,
}
events, err := s.auditRepo.List(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list audit events: %w", err)
}
// Filter by action on client side (repository may not filter by action directly)
var filtered []*domain.AuditEvent
for _, e := range events {
if e.Action == action {
filtered = append(filtered, e)
}
}
return filtered, nil
}
+213
View File
@@ -0,0 +1,213 @@
package service
import (
"context"
"fmt"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// CertificateService provides business logic for certificate management.
type CertificateService struct {
certRepo repository.CertificateRepository
policyService *PolicyService
auditService *AuditService
}
// NewCertificateService creates a new certificate service.
func NewCertificateService(
certRepo repository.CertificateRepository,
policyService *PolicyService,
auditService *AuditService,
) *CertificateService {
return &CertificateService{
certRepo: certRepo,
policyService: policyService,
auditService: auditService,
}
}
// List returns a paginated list of certificates matching the filter.
func (s *CertificateService) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
certs, total, err := s.certRepo.List(ctx, filter)
if err != nil {
return nil, 0, fmt.Errorf("failed to list certificates: %w", err)
}
return certs, total, nil
}
// Get retrieves a certificate by ID.
func (s *CertificateService) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
cert, err := s.certRepo.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get certificate %s: %w", id, err)
}
return cert, nil
}
// Create validates and stores a new certificate.
func (s *CertificateService) Create(ctx context.Context, cert *domain.ManagedCertificate, actor string) error {
// Validate certificate structure
if cert.ID == "" || cert.CommonName == "" || cert.IssuerID == "" {
return fmt.Errorf("invalid certificate: missing required fields")
}
// Run policy validation
violations, err := s.policyService.ValidateCertificate(ctx, cert)
if err != nil {
return fmt.Errorf("policy validation failed: %w", err)
}
if len(violations) > 0 {
// Record violations but do not block creation
for _, v := range violations {
_ = s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"policy_violation_detected", "certificate", cert.ID,
map[string]interface{}{"rule_id": v.RuleID, "message": v.Message})
}
}
// Store certificate
if err := s.certRepo.Create(ctx, cert); err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}
// Record audit event
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"certificate_created", "certificate", cert.ID,
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
// Log but don't fail the operation
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// Update modifies an existing certificate.
func (s *CertificateService) Update(ctx context.Context, cert *domain.ManagedCertificate, actor string) error {
existing, err := s.certRepo.Get(ctx, cert.ID)
if err != nil {
return fmt.Errorf("failed to fetch existing certificate: %w", err)
}
// Run policy validation on updated cert
violations, err := s.policyService.ValidateCertificate(ctx, cert)
if err != nil {
return fmt.Errorf("policy validation failed: %w", err)
}
if len(violations) > 0 {
for _, v := range violations {
_ = s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"policy_violation_detected", "certificate", cert.ID,
map[string]interface{}{"rule_id": v.RuleID, "message": v.Message})
}
}
// Store updated certificate
if err := s.certRepo.Update(ctx, cert); err != nil {
return fmt.Errorf("failed to update certificate: %w", err)
}
// Record audit event with diff info
changes := map[string]interface{}{}
if existing.Status != cert.Status {
changes["status"] = fmt.Sprintf("%s -> %s", existing.Status, cert.Status)
}
if existing.ExpiresAt != cert.ExpiresAt {
changes["expiry"] = fmt.Sprintf("%s -> %s", existing.ExpiresAt, cert.ExpiresAt)
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"certificate_updated", "certificate", cert.ID, changes); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// Archive marks a certificate as archived.
func (s *CertificateService) Archive(ctx context.Context, id string, actor string) error {
cert, err := s.certRepo.Get(ctx, id)
if err != nil {
return fmt.Errorf("failed to fetch certificate: %w", err)
}
if err := s.certRepo.Archive(ctx, id); err != nil {
return fmt.Errorf("failed to archive certificate: %w", err)
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"certificate_archived", "certificate", id,
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// GetVersions returns all versions of a certificate.
func (s *CertificateService) GetVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
versions, err := s.certRepo.ListVersions(ctx, certID)
if err != nil {
return nil, fmt.Errorf("failed to list certificate versions: %w", err)
}
return versions, nil
}
// TriggerRenewal initiates a renewal job if the certificate is eligible.
func (s *CertificateService) TriggerRenewal(ctx context.Context, certID string, actor string) error {
cert, err := s.certRepo.Get(ctx, certID)
if err != nil {
return fmt.Errorf("failed to fetch certificate: %w", err)
}
// Validate eligibility
if cert.Status == domain.CertificateStatusArchived {
return fmt.Errorf("cannot renew archived certificate")
}
if cert.Status == domain.CertificateStatusExpired {
return fmt.Errorf("cannot renew expired certificate; reissue instead")
}
// Check if already renewing
if cert.Status == domain.CertificateStatusRenewalInProgress {
return fmt.Errorf("certificate renewal already in progress")
}
// Update status
cert.Status = domain.CertificateStatusRenewalInProgress
if err := s.certRepo.Update(ctx, cert); err != nil {
return fmt.Errorf("failed to update certificate status: %w", err)
}
// Record audit event
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"renewal_triggered", "certificate", certID,
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// TriggerDeployment creates deployment jobs for all targets of a certificate.
func (s *CertificateService) TriggerDeployment(ctx context.Context, certID string, actor string) error {
cert, err := s.certRepo.Get(ctx, certID)
if err != nil {
return fmt.Errorf("failed to fetch certificate: %w", err)
}
if cert.Status == domain.CertificateStatusArchived {
return fmt.Errorf("cannot deploy archived certificate")
}
// Note: In practice, the DeploymentService would be called to create jobs.
// This is a placeholder for the coordination logic.
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"deployment_triggered", "certificate", certID,
map[string]interface{}{"common_name": cert.CommonName}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
+293
View File
@@ -0,0 +1,293 @@
package service
import (
"context"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// DeploymentService manages certificate deployment to targets via agents.
type DeploymentService struct {
jobRepo repository.JobRepository
targetRepo repository.TargetRepository
agentRepo repository.AgentRepository
certRepo repository.CertificateRepository
auditService *AuditService
notificationSvc *NotificationService
}
// NewDeploymentService creates a new deployment service.
func NewDeploymentService(
jobRepo repository.JobRepository,
targetRepo repository.TargetRepository,
agentRepo repository.AgentRepository,
certRepo repository.CertificateRepository,
auditService *AuditService,
notificationSvc *NotificationService,
) *DeploymentService {
return &DeploymentService{
jobRepo: jobRepo,
targetRepo: targetRepo,
agentRepo: agentRepo,
certRepo: certRepo,
auditService: auditService,
notificationSvc: notificationSvc,
}
}
// CreateDeploymentJobs creates a job for each target of a certificate.
func (s *DeploymentService) CreateDeploymentJobs(ctx context.Context, certID string) ([]string, error) {
// Fetch all targets for this certificate
targets, err := s.targetRepo.ListByCertificate(ctx, certID)
if err != nil {
return nil, fmt.Errorf("failed to list targets: %w", err)
}
if len(targets) == 0 {
return nil, fmt.Errorf("no targets found for certificate %s", certID)
}
var jobIDs []string
// Create a deployment job for each target
for _, target := range targets {
job := &domain.Job{
ID: generateID("job"),
CertificateID: certID,
Type: domain.JobTypeDeployment,
Status: domain.JobStatusPending,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
// Store target info in TargetID field
if target.ID != "" {
job.TargetID = &target.ID
}
if err := s.jobRepo.Create(ctx, job); err != nil {
fmt.Printf("failed to create deployment job for target %s: %v\n", target.ID, err)
continue
}
jobIDs = append(jobIDs, job.ID)
}
if len(jobIDs) == 0 {
return nil, fmt.Errorf("failed to create any deployment jobs")
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"deployment_jobs_created", "certificate", certID,
map[string]interface{}{"target_count": len(targets), "job_count": len(jobIDs)})
return jobIDs, nil
}
// ProcessDeploymentJob handles a deployment job by coordinating with an agent.
func (s *DeploymentService) ProcessDeploymentJob(ctx context.Context, job *domain.Job) error {
// Update job status to in-progress
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusRunning, ""); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Fetch certificate
cert, err := s.certRepo.Get(ctx, job.CertificateID)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("certificate fetch failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to fetch certificate: %w", err)
}
// Fetch target
var targetID string
if job.TargetID != nil {
targetID = *job.TargetID
}
if targetID == "" {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, "target_id not found in job")
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("target_id not found in job")
}
target, err := s.targetRepo.Get(ctx, targetID)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("target fetch failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to fetch target: %w", err)
}
// Verify agent is available
agentID := target.AgentID
agent, err := s.agentRepo.Get(ctx, agentID)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("agent fetch failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to fetch agent: %w", err)
}
// Check agent heartbeat (must be within last 5 minutes)
if agent.LastHeartbeatAt != nil && time.Since(*agent.LastHeartbeatAt) > 5*time.Minute {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, "agent is offline")
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
_ = s.notificationSvc.SendDeploymentNotification(ctx, cert, target, false, fmt.Errorf("agent offline"))
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"deployment_job_failed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": job.ID, "reason": "agent offline", "target_id": targetID})
return fmt.Errorf("agent %s is offline", agentID)
}
// In a real implementation, the agent would poll GetPendingWork() to fetch this job.
// The control plane would wait for the agent to complete the work asynchronously.
// For now, we mark it as pending and rely on agent polling.
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"deployment_job_dispatched", "certificate", job.CertificateID,
map[string]interface{}{"job_id": job.ID, "target_id": targetID, "agent_id": agentID})
return nil
}
// ValidateDeployment checks the deployment status of a certificate on a target.
func (s *DeploymentService) ValidateDeployment(ctx context.Context, certID string, targetID string) (bool, error) {
// List deployment jobs for this certificate and target
jobs, err := s.jobRepo.ListByCertificate(ctx, certID)
if err != nil {
return false, fmt.Errorf("failed to list jobs: %w", err)
}
for _, job := range jobs {
if job.Type != domain.JobTypeDeployment {
continue
}
// Check if this job is for the target
if job.TargetID == nil || *job.TargetID != targetID {
continue
}
// Check if the most recent job for this target succeeded
if job.Status == domain.JobStatusCompleted {
return true, nil
}
if job.Status == domain.JobStatusFailed {
if job.LastError != nil {
return false, fmt.Errorf("deployment failed: %s", *job.LastError)
}
return false, fmt.Errorf("deployment failed")
}
// Still in progress
return false, fmt.Errorf("deployment in progress")
}
// No deployment job found
return false, fmt.Errorf("no deployment job found for target %s", targetID)
}
// MarkDeploymentComplete marks a deployment job as successfully completed.
// This is called by agents after they finish deploying a certificate.
func (s *DeploymentService) MarkDeploymentComplete(ctx context.Context, jobID string) error {
job, err := s.jobRepo.Get(ctx, jobID)
if err != nil {
return fmt.Errorf("failed to fetch job: %w", err)
}
if err := s.jobRepo.UpdateStatus(ctx, jobID, domain.JobStatusCompleted, ""); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Fetch certificate and target for notification
cert, err := s.certRepo.Get(ctx, job.CertificateID)
if err != nil {
fmt.Printf("failed to fetch certificate for notification: %v\n", err)
return nil
}
var targetID string
if job.TargetID != nil {
targetID = *job.TargetID
}
if targetID != "" {
target, err := s.targetRepo.Get(ctx, targetID)
if err != nil {
fmt.Printf("failed to fetch target for notification: %v\n", err)
return nil
}
// Send deployment success notification
if err := s.notificationSvc.SendDeploymentNotification(ctx, cert, target, true, nil); err != nil {
fmt.Printf("failed to send deployment notification: %v\n", err)
}
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"deployment_job_completed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": jobID, "target_id": targetID})
return nil
}
// MarkDeploymentFailed marks a deployment job as failed.
// Called by agents when deployment fails.
func (s *DeploymentService) MarkDeploymentFailed(ctx context.Context, jobID string, errMsg string) error {
job, err := s.jobRepo.Get(ctx, jobID)
if err != nil {
return fmt.Errorf("failed to fetch job: %w", err)
}
if err := s.jobRepo.UpdateStatus(ctx, jobID, domain.JobStatusFailed, errMsg); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Fetch certificate and target for notification
cert, err := s.certRepo.Get(ctx, job.CertificateID)
if err != nil {
fmt.Printf("failed to fetch certificate for notification: %v\n", err)
return nil
}
var targetID string
if job.TargetID != nil {
targetID = *job.TargetID
}
if targetID != "" {
target, err := s.targetRepo.Get(ctx, targetID)
if err != nil {
fmt.Printf("failed to fetch target for notification: %v\n", err)
return nil
}
// Send deployment failure notification
if err := s.notificationSvc.SendDeploymentNotification(ctx, cert, target, false, fmt.Errorf(errMsg)); err != nil {
fmt.Printf("failed to send deployment notification: %v\n", err)
}
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"deployment_job_failed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": jobID, "target_id": targetID, "error": errMsg})
return nil
}
+199
View File
@@ -0,0 +1,199 @@
package service
import (
"context"
"fmt"
"log/slog"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// JobService manages job processing and status tracking.
// It coordinates between the scheduler and various job-specific services.
type JobService struct {
jobRepo repository.JobRepository
renewalService *RenewalService
deploymentService *DeploymentService
logger *slog.Logger
}
// NewJobService creates a new job service.
func NewJobService(
jobRepo repository.JobRepository,
renewalService *RenewalService,
deploymentService *DeploymentService,
logger *slog.Logger,
) *JobService {
return &JobService{
jobRepo: jobRepo,
renewalService: renewalService,
deploymentService: deploymentService,
logger: logger,
}
}
// ProcessPendingJobs fetches and processes all pending jobs.
// It routes jobs to the appropriate service based on job type and handles errors gracefully.
func (s *JobService) ProcessPendingJobs(ctx context.Context) error {
// Fetch pending jobs
pendingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
if err != nil {
return fmt.Errorf("failed to list pending jobs: %w", err)
}
if len(pendingJobs) == 0 {
s.logger.Debug("no pending jobs to process")
return nil
}
s.logger.Info("processing pending jobs", "count", len(pendingJobs))
var processedCount int
var failedCount int
// Process each job
for _, job := range pendingJobs {
if err := s.processJob(ctx, job); err != nil {
s.logger.Error("failed to process job",
"job_id", job.ID,
"job_type", job.Type,
"error", err)
failedCount++
continue
}
processedCount++
}
s.logger.Info("job processing completed",
"processed", processedCount,
"failed", failedCount,
"total", len(pendingJobs))
return nil
}
// processJob routes a single job to the appropriate service based on type.
func (s *JobService) processJob(ctx context.Context, job *domain.Job) error {
s.logger.Debug("processing job",
"job_id", job.ID,
"job_type", job.Type,
"certificate_id", job.CertificateID)
switch job.Type {
case domain.JobTypeRenewal:
return s.renewalService.ProcessRenewalJob(ctx, job)
case domain.JobTypeDeployment:
return s.deploymentService.ProcessDeploymentJob(ctx, job)
case domain.JobTypeIssuance:
return s.processIssuanceJob(ctx, job)
case domain.JobTypeValidation:
return s.processValidationJob(ctx, job)
default:
return fmt.Errorf("unknown job type: %s", job.Type)
}
}
// processIssuanceJob handles a certificate issuance job.
// This is a placeholder that documents the flow.
// TODO: Implement actual issuance job processing if needed.
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")
}
// processValidationJob handles a certificate validation job.
// This is a placeholder that documents the flow.
// TODO: Implement actual validation job processing if needed.
func (s *JobService) processValidationJob(ctx context.Context, job *domain.Job) error {
s.logger.Debug("processing validation job", "job_id", job.ID)
// TODO: Implement validation job processing
// In production:
// 1. Fetch the certificate
// 2. For each target, call target connector ValidateDeployment
// 3. Aggregate results
// 4. Update job status based on results
// 5. Send notification if any validation fails
return fmt.Errorf("validation job processing not yet implemented")
}
// RetryFailedJobs finds failed jobs and resets them for retry.
// It only retries jobs that haven't exceeded max attempts.
func (s *JobService) RetryFailedJobs(ctx context.Context, maxRetries int) error {
s.logger.Debug("retrying failed jobs", "max_retries", maxRetries)
failedJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusFailed)
if err != nil {
return fmt.Errorf("failed to fetch failed jobs: %w", err)
}
var retriedCount int
for _, job := range failedJobs {
// Check if we can retry (Attempts < MaxAttempts)
if job.Attempts >= job.MaxAttempts {
s.logger.Debug("job exceeded max retries",
"job_id", job.ID,
"attempts", job.Attempts,
"max_attempts", job.MaxAttempts)
continue
}
// Reset status to pending for retry
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusPending, ""); err != nil {
s.logger.Error("failed to reset job status for retry",
"job_id", job.ID,
"error", err)
continue
}
retriedCount++
}
s.logger.Info("failed jobs retry completed",
"retried", retriedCount,
"total_failed", len(failedJobs))
return nil
}
// GetJobStatus returns the current status of a job.
func (s *JobService) GetJobStatus(ctx context.Context, jobID string) (*domain.Job, error) {
job, err := s.jobRepo.Get(ctx, jobID)
if err != nil {
return nil, fmt.Errorf("failed to fetch job: %w", err)
}
return job, nil
}
// CancelJob cancels a pending or running job.
func (s *JobService) CancelJob(ctx context.Context, jobID string) error {
job, err := s.jobRepo.Get(ctx, jobID)
if err != nil {
return fmt.Errorf("failed to fetch job: %w", err)
}
if job.Status != domain.JobStatusPending && job.Status != domain.JobStatusRunning {
return fmt.Errorf("cannot cancel job with status %s", job.Status)
}
if err := s.jobRepo.UpdateStatus(ctx, jobID, domain.JobStatusCancelled, "cancelled by user"); err != nil {
return fmt.Errorf("failed to cancel job: %w", err)
}
s.logger.Info("job cancelled", "job_id", jobID)
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package service
import (
"context"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// NotificationService provides business logic for managing notifications.
type NotificationService struct {
notifRepo repository.NotificationRepository
notifierRegistry map[string]Notifier
}
// Notifier defines the interface for notification channels (email, Slack, webhooks, etc.).
type Notifier interface {
// Send delivers a notification and returns error if unsuccessful.
Send(ctx context.Context, recipient string, subject string, body string) error
// Channel returns the channel identifier (e.g., "email", "slack").
Channel() string
}
// NewNotificationService creates a new notification service.
func NewNotificationService(
notifRepo repository.NotificationRepository,
notifierRegistry map[string]Notifier,
) *NotificationService {
return &NotificationService{
notifRepo: notifRepo,
notifierRegistry: notifierRegistry,
}
}
// SendExpirationWarning sends a certificate expiration warning.
func (s *NotificationService) SendExpirationWarning(ctx context.Context, cert *domain.ManagedCertificate, daysUntilExpiry int) error {
body := fmt.Sprintf(
"The certificate for %s will expire in %d days (%s).\n\nPlease schedule renewal.",
cert.CommonName, daysUntilExpiry, cert.ExpiresAt.Format("2006-01-02"),
)
// Create notification record
notif := &domain.NotificationEvent{
ID: generateID("notif"),
CertificateID: &cert.ID,
Type: domain.NotificationTypeExpirationWarning,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Message: body,
Status: "pending",
CreatedAt: time.Now(),
}
if err := s.notifRepo.Create(ctx, notif); err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
// Attempt immediate send
return s.sendNotification(ctx, notif)
}
// SendRenewalNotification sends a renewal success or failure notification.
func (s *NotificationService) SendRenewalNotification(ctx context.Context, cert *domain.ManagedCertificate, success bool, err error) error {
var body string
if success {
body = fmt.Sprintf(
"The certificate for %s has been successfully renewed.\n\nNew expiry: %s",
cert.CommonName, cert.ExpiresAt.Format("2006-01-02"),
)
} else {
body = fmt.Sprintf(
"The certificate for %s failed to renew.\n\nError: %v\n\nPlease investigate.",
cert.CommonName, err,
)
}
var notifType domain.NotificationType
if success {
notifType = domain.NotificationTypeRenewalSuccess
} else {
notifType = domain.NotificationTypeRenewalFailure
}
notif := &domain.NotificationEvent{
ID: generateID("notif"),
CertificateID: &cert.ID,
Type: notifType,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Message: body,
Status: "pending",
CreatedAt: time.Now(),
}
if err := s.notifRepo.Create(ctx, notif); err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
return s.sendNotification(ctx, notif)
}
// SendDeploymentNotification sends a deployment success or failure notification.
func (s *NotificationService) SendDeploymentNotification(ctx context.Context, cert *domain.ManagedCertificate, target *domain.DeploymentTarget, success bool, err error) error {
var body string
if success {
body = fmt.Sprintf(
"The certificate for %s has been successfully deployed to %s.",
cert.CommonName, target.Name,
)
} else {
body = fmt.Sprintf(
"The certificate for %s failed to deploy to %s.\n\nError: %v\n\nPlease investigate.",
cert.CommonName, target.Name, err,
)
}
notifType := domain.NotificationTypeDeploymentSuccess
if !success {
notifType = domain.NotificationTypeDeploymentFailure
}
notif := &domain.NotificationEvent{
ID: generateID("notif"),
CertificateID: &cert.ID,
Type: notifType,
Channel: domain.NotificationChannelEmail,
Recipient: cert.OwnerID,
Message: body,
Status: "pending",
CreatedAt: time.Now(),
}
if err := s.notifRepo.Create(ctx, notif); err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
return s.sendNotification(ctx, notif)
}
// ProcessPendingNotifications sends all pending notifications in batch.
func (s *NotificationService) ProcessPendingNotifications(ctx context.Context) error {
filter := &repository.NotificationFilter{
Status: "pending",
PerPage: 1000,
}
pending, err := s.notifRepo.List(ctx, filter)
if err != nil {
return fmt.Errorf("failed to list pending notifications: %w", err)
}
var failedCount int
for _, notif := range pending {
if err := s.sendNotification(ctx, notif); err != nil {
fmt.Printf("failed to send notification %s: %v\n", notif.ID, err)
failedCount++
}
}
if failedCount > 0 {
return fmt.Errorf("failed to send %d out of %d notifications", failedCount, len(pending))
}
return nil
}
// sendNotification delivers a single notification via the appropriate channel.
func (s *NotificationService) sendNotification(ctx context.Context, notif *domain.NotificationEvent) error {
// Get the appropriate notifier for the channel
notifier, ok := s.notifierRegistry[string(notif.Channel)]
if !ok {
return fmt.Errorf("notifier not found for channel %s", notif.Channel)
}
// Send the notification
if err := notifier.Send(ctx, notif.Recipient, string(notif.Type), notif.Message); err != nil {
// Update status to failed
_ = s.notifRepo.UpdateStatus(ctx, notif.ID, "failed", time.Time{})
return fmt.Errorf("failed to send via %s: %w", notif.Channel, err)
}
// Update status to sent
if err := s.notifRepo.UpdateStatus(ctx, notif.ID, "sent", time.Now()); err != nil {
fmt.Printf("failed to update notification status: %v\n", err)
}
return nil
}
// RegisterNotifier registers a new notification channel handler.
func (s *NotificationService) RegisterNotifier(channel string, notifier Notifier) {
if s.notifierRegistry == nil {
s.notifierRegistry = make(map[string]Notifier)
}
s.notifierRegistry[channel] = notifier
}
// GetNotificationHistory returns all notifications for a certificate.
func (s *NotificationService) GetNotificationHistory(ctx context.Context, certID string) ([]*domain.NotificationEvent, error) {
filter := &repository.NotificationFilter{
CertificateID: certID,
PerPage: 1000,
}
notifications, err := s.notifRepo.List(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list notifications: %w", err)
}
return notifications, nil
}
+229
View File
@@ -0,0 +1,229 @@
package service
import (
"context"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// PolicyService provides business logic for compliance policy management.
type PolicyService struct {
policyRepo repository.PolicyRepository
auditService *AuditService
}
// NewPolicyService creates a new policy service.
func NewPolicyService(
policyRepo repository.PolicyRepository,
auditService *AuditService,
) *PolicyService {
return &PolicyService{
policyRepo: policyRepo,
auditService: auditService,
}
}
// ValidateCertificate runs all enabled policy rules against a certificate.
func (s *PolicyService) ValidateCertificate(ctx context.Context, cert *domain.ManagedCertificate) ([]*domain.PolicyViolation, error) {
rules, err := s.policyRepo.ListRules(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list policy rules: %w", err)
}
var violations []*domain.PolicyViolation
for _, rule := range rules {
// Skip disabled rules
if !rule.Enabled {
continue
}
// Evaluate rule against certificate
v, err := s.evaluateRule(rule, cert)
if err != nil {
fmt.Printf("failed to evaluate rule %s: %v\n", rule.ID, err)
continue
}
if v != nil {
violations = append(violations, v)
}
}
return violations, nil
}
// evaluateRule checks if a certificate violates a single policy rule.
func (s *PolicyService) evaluateRule(rule *domain.PolicyRule, cert *domain.ManagedCertificate) (*domain.PolicyViolation, error) {
switch rule.Type {
case domain.PolicyTypeAllowedIssuers:
// Restrict to specific issuers
// Note: In a production implementation, we would parse rule.Config to extract parameters
if cert.IssuerID == "" {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: domain.PolicySeverityWarning,
Message: "certificate has no issuer assigned",
CreatedAt: time.Now(),
}, nil
}
case domain.PolicyTypeAllowedDomains:
// Ensure certificate domains are in allowed list
if len(cert.SANs) == 0 {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: domain.PolicySeverityWarning,
Message: "certificate has no subject alternative names",
CreatedAt: time.Now(),
}, nil
}
case domain.PolicyTypeRequiredMetadata:
// Ensure certificate has required metadata/tags
if len(cert.Tags) == 0 {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: domain.PolicySeverityWarning,
Message: "certificate has no tags or metadata",
CreatedAt: time.Now(),
}, nil
}
case domain.PolicyTypeAllowedEnvironments:
// Restrict to specific environments
if cert.Environment == "" {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: domain.PolicySeverityWarning,
Message: "certificate has no environment assigned",
CreatedAt: time.Now(),
}, nil
}
case domain.PolicyTypeRenewalLeadTime:
// Ensure renewal begins before certificate expires
daysUntilExpiry := time.Until(cert.ExpiresAt).Hours() / 24
if daysUntilExpiry < 30 && daysUntilExpiry > 0 {
return &domain.PolicyViolation{
ID: generateID("violation"),
RuleID: rule.ID,
CertificateID: cert.ID,
Severity: domain.PolicySeverityWarning,
Message: fmt.Sprintf("certificate expires in %.1f days, plan renewal soon", daysUntilExpiry),
CreatedAt: time.Now(),
}, nil
}
default:
return nil, fmt.Errorf("unknown policy rule type: %s", rule.Type)
}
return nil, nil
}
// CreateRule stores a new policy rule.
func (s *PolicyService) CreateRule(ctx context.Context, rule *domain.PolicyRule, actor string) error {
if rule.ID == "" {
rule.ID = generateID("rule")
}
if rule.CreatedAt.IsZero() {
rule.CreatedAt = time.Now()
}
if err := s.policyRepo.CreateRule(ctx, rule); err != nil {
return fmt.Errorf("failed to create policy rule: %w", err)
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"policy_rule_created", "policy", rule.ID,
map[string]interface{}{"rule_type": rule.Type}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// UpdateRule modifies an existing policy rule.
func (s *PolicyService) UpdateRule(ctx context.Context, rule *domain.PolicyRule, actor string) error {
existing, err := s.policyRepo.GetRule(ctx, rule.ID)
if err != nil {
return fmt.Errorf("failed to fetch existing rule: %w", err)
}
rule.UpdatedAt = time.Now()
if err := s.policyRepo.UpdateRule(ctx, rule); err != nil {
return fmt.Errorf("failed to update policy rule: %w", err)
}
changes := map[string]interface{}{}
if existing.Enabled != rule.Enabled {
changes["enabled"] = rule.Enabled
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"policy_rule_updated", "policy", rule.ID, changes); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// GetRule retrieves a policy rule by ID.
func (s *PolicyService) GetRule(ctx context.Context, id string) (*domain.PolicyRule, error) {
rule, err := s.policyRepo.GetRule(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch policy rule: %w", err)
}
return rule, nil
}
// ListRules returns all policy rules.
func (s *PolicyService) ListRules(ctx context.Context) ([]*domain.PolicyRule, error) {
rules, err := s.policyRepo.ListRules(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list policy rules: %w", err)
}
return rules, nil
}
// DeleteRule removes a policy rule.
func (s *PolicyService) DeleteRule(ctx context.Context, id string, actor string) error {
rule, err := s.policyRepo.GetRule(ctx, id)
if err != nil {
return fmt.Errorf("failed to fetch rule: %w", err)
}
if err := s.policyRepo.DeleteRule(ctx, id); err != nil {
return fmt.Errorf("failed to delete policy rule: %w", err)
}
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
"policy_rule_deleted", "policy", id,
map[string]interface{}{"rule_type": rule.Type}); err != nil {
fmt.Printf("failed to record audit event: %v\n", err)
}
return nil
}
// ListViolations returns policy violations matching filter criteria.
func (s *PolicyService) ListViolations(ctx context.Context, filter *repository.AuditFilter) ([]*domain.PolicyViolation, error) {
violations, err := s.policyRepo.ListViolations(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to list policy violations: %w", err)
}
return violations, nil
}
+225
View File
@@ -0,0 +1,225 @@
package service
import (
"context"
"fmt"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// RenewalService manages certificate renewal workflows.
type RenewalService struct {
certRepo repository.CertificateRepository
jobRepo repository.JobRepository
auditService *AuditService
notificationSvc *NotificationService
issuerRegistry map[string]IssuerConnector
}
// IssuerConnector defines the interface for interacting with certificate issuers.
type IssuerConnector interface {
// RenewCertificate renews a certificate and returns the new certificate PEM.
RenewCertificate(ctx context.Context, csr []byte) ([]byte, error)
// GetCertificateChain returns the issuer's certificate chain.
GetCertificateChain(ctx context.Context) ([]byte, error)
}
// NewRenewalService creates a new renewal service.
func NewRenewalService(
certRepo repository.CertificateRepository,
jobRepo repository.JobRepository,
auditService *AuditService,
notificationSvc *NotificationService,
issuerRegistry map[string]IssuerConnector,
) *RenewalService {
return &RenewalService{
certRepo: certRepo,
jobRepo: jobRepo,
auditService: auditService,
notificationSvc: notificationSvc,
issuerRegistry: issuerRegistry,
}
}
// CheckExpiringCertificates identifies certificates needing renewal based on policy windows.
func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
// Default renewal window: 30 days before expiry
renewalWindow := time.Now().AddDate(0, 0, 30)
expiring, err := s.certRepo.GetExpiringCertificates(ctx, renewalWindow)
if err != nil {
return fmt.Errorf("failed to fetch expiring certificates: %w", err)
}
for _, cert := range expiring {
// Skip if already renewing or archived
if cert.Status == domain.CertificateStatusRenewalInProgress || cert.Status == domain.CertificateStatusArchived {
continue
}
// Calculate days until expiry
daysUntil := time.Until(cert.ExpiresAt).Hours() / 24
// Create renewal job
job := &domain.Job{
ID: generateID("job"),
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusPending,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
if err := s.jobRepo.Create(ctx, job); err != nil {
fmt.Printf("failed to create renewal job for cert %s: %v\n", cert.ID, err)
continue
}
// Send expiration warning notification
if err := s.notificationSvc.SendExpirationWarning(ctx, cert, int(daysUntil)); err != nil {
fmt.Printf("failed to send expiration warning for cert %s: %v\n", cert.ID, err)
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"renewal_job_created", "certificate", cert.ID,
map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID})
}
return nil
}
// ProcessRenewalJob executes a renewal job: call issuer, store new version, update cert status.
func (s *RenewalService) ProcessRenewalJob(ctx context.Context, job *domain.Job) error {
// Update job status to in-progress
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusRunning, ""); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Fetch certificate
cert, err := s.certRepo.Get(ctx, job.CertificateID)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("certificate fetch failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to fetch certificate: %w", err)
}
// Get issuer connector
issuerID := cert.IssuerID
if issuerID == "" {
return fmt.Errorf("certificate has no issuer assigned")
}
connector, ok := s.issuerRegistry[issuerID]
if !ok {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed,
fmt.Sprintf("issuer connector not found for %s", issuerID))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("issuer connector not found for %s", issuerID)
}
// TODO: In production, fetch CSR from agent or generate new CSR
// For now, we'd use cert.CSR or generate a new one from the private key
csr := []byte{} // placeholder
// Call issuer to renew
certPEM, err := connector.RenewCertificate(ctx, csr)
if err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("issuer renewal failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
// Send failure notification
_ = s.notificationSvc.SendRenewalNotification(ctx, cert, false, err)
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"renewal_job_failed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": job.ID, "error": err.Error()})
return fmt.Errorf("issuer renewal failed: %w", err)
}
// Create new certificate version
version := &domain.CertificateVersion{
ID: generateID("certver"),
CertificateID: job.CertificateID,
SerialNumber: fmt.Sprintf("renewed-%d", time.Now().Unix()),
PEMChain: string(certPEM),
CreatedAt: time.Now(),
}
if err := s.certRepo.CreateVersion(ctx, version); err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("version creation failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to create certificate version: %w", err)
}
// Update certificate status
cert.Status = domain.CertificateStatusActive
if err := s.certRepo.Update(ctx, cert); err != nil {
updateErr := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusFailed, fmt.Sprintf("cert update failed: %v", err))
if updateErr != nil {
fmt.Printf("failed to update job status: %v\n", updateErr)
}
return fmt.Errorf("failed to update certificate: %w", err)
}
// Mark job as completed
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusCompleted, ""); err != nil {
return fmt.Errorf("failed to update job status: %w", err)
}
// Send success notification
if err := s.notificationSvc.SendRenewalNotification(ctx, cert, true, nil); err != nil {
fmt.Printf("failed to send renewal notification: %v\n", err)
}
// Record audit event
_ = s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"renewal_job_completed", "certificate", job.CertificateID,
map[string]interface{}{"job_id": job.ID, "serial": version.SerialNumber})
return nil
}
// Retry attempts to reprocess failed renewal jobs with exponential backoff.
func (s *RenewalService) RetryFailedJobs(ctx context.Context, maxRetries int) error {
failedJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusFailed)
if err != nil {
return fmt.Errorf("failed to fetch failed jobs: %w", err)
}
for _, job := range failedJobs {
if job.Type != domain.JobTypeRenewal {
continue
}
// Check if we've exceeded max attempts
if job.Attempts >= job.MaxAttempts {
continue
}
// Reset status to pending for retry
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusPending, ""); err != nil {
fmt.Printf("failed to reset job status for retry: %v\n", err)
continue
}
}
return nil
}
// generateID is a helper to generate unique IDs. In production, use a proper ID generator.
func generateID(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}