mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 01:39:01 +00:00
Initial scaffold: certificate control plane v0.1.0
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user