mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:11:38 +00:00
1a9e3ab8ce
Agents now report OS, architecture, IP address, hostname, and version via heartbeat using runtime.GOOS, runtime.GOARCH, and net.Dial. New migration adds columns to agents table. Heartbeat handler, service, and repository updated to accept and persist metadata. GUI shows OS/Arch in agent list and full system info in agent detail page. Apache httpd connector: separate cert/chain/key files, apachectl configtest validation, graceful reload. HAProxy connector: combined PEM file (cert+chain+key), optional config validation, reload. Both wired into agent binary's target connector switch. 14 tests for new connectors. All existing tests updated for new Heartbeat/UpdateHeartbeat signatures. Docs updated across README, architecture, concepts, and connectors guides. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
365 lines
10 KiB
Go
365 lines
10 KiB
Go
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, metadata *domain.AgentMetadata) error
|
|
CSRSubmit(agentID string, csrPEM string) (string, error)
|
|
CSRSubmitForCert(agentID string, certID string, csrPEM string) (string, error)
|
|
CertificatePickup(agentID, certID string) (string, error)
|
|
GetWork(agentID string) ([]domain.Job, error)
|
|
GetWorkWithTargets(agentID string) ([]domain.WorkItem, error)
|
|
UpdateJobStatus(agentID string, jobID string, status string, errMsg string) error
|
|
}
|
|
|
|
// AgentHandler handles HTTP requests for agent operations.
|
|
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
|
|
}
|
|
|
|
// Validate required fields
|
|
if err := ValidateRequired("name", agent.Name); err != nil {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
|
return
|
|
}
|
|
if err := ValidateStringLength("name", agent.Name, 128); err != nil {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
|
return
|
|
}
|
|
if err := ValidateRequired("hostname", agent.Hostname); err != nil {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), 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]
|
|
|
|
// Parse optional metadata from request body
|
|
var metadata *domain.AgentMetadata
|
|
if r.Body != nil {
|
|
var body struct {
|
|
Version string `json:"version"`
|
|
Hostname string `json:"hostname"`
|
|
OS string `json:"os"`
|
|
Architecture string `json:"architecture"`
|
|
IPAddress string `json:"ip_address"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err == nil {
|
|
if body.Version != "" || body.Hostname != "" || body.OS != "" || body.Architecture != "" || body.IPAddress != "" {
|
|
metadata = &domain.AgentMetadata{
|
|
Version: body.Version,
|
|
Hostname: body.Hostname,
|
|
OS: body.OS,
|
|
Architecture: body.Architecture,
|
|
IPAddress: body.IPAddress,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := h.svc.Heartbeat(agentID, metadata); 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
|
|
// Optionally accepts a certificate_id to sign the CSR for a specific certificate.
|
|
func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
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"`
|
|
CertificateID string `json:"certificate_id,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
|
return
|
|
}
|
|
|
|
// Validate CSR PEM
|
|
if err := ValidateCSRPEM(req.CSRPEM); err != nil {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
|
return
|
|
}
|
|
|
|
var status string
|
|
var err error
|
|
|
|
// If certificate_id is provided, sign the CSR for that specific certificate
|
|
if req.CertificateID != "" {
|
|
status, err = h.svc.CSRSubmitForCert(agentID, req.CertificateID, req.CSRPEM)
|
|
} else {
|
|
status, err = h.svc.CSRSubmit(agentID, req.CSRPEM)
|
|
}
|
|
|
|
if err != nil {
|
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to submit CSR", requestID)
|
|
return
|
|
}
|
|
|
|
response := map[string]string{
|
|
"status": status,
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// AgentGetWork returns pending deployment jobs for an agent.
|
|
// GET /api/v1/agents/{id}/work
|
|
func (h AgentHandler) AgentGetWork(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
|
|
requestID := middleware.GetRequestID(r.Context())
|
|
|
|
// Extract agent ID from path /api/v1/agents/{id}/work
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 2 || parts[0] == "" {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID is required", requestID)
|
|
return
|
|
}
|
|
agentID := parts[0]
|
|
|
|
workItems, err := h.svc.GetWorkWithTargets(agentID)
|
|
if err != nil {
|
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get pending work", requestID)
|
|
return
|
|
}
|
|
|
|
if workItems == nil {
|
|
workItems = []domain.WorkItem{}
|
|
}
|
|
|
|
JSON(w, http.StatusOK, map[string]interface{}{
|
|
"jobs": workItems,
|
|
"count": len(workItems),
|
|
})
|
|
}
|
|
|
|
// AgentReportJobStatus receives a job status report from an agent.
|
|
// POST /api/v1/agents/{id}/jobs/{job_id}/status
|
|
func (h AgentHandler) AgentReportJobStatus(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
|
|
requestID := middleware.GetRequestID(r.Context())
|
|
|
|
// Extract agent ID and job ID from path /api/v1/agents/{id}/jobs/{job_id}/status
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 4 || parts[0] == "" || parts[2] == "" {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, "Agent ID and Job ID are required", requestID)
|
|
return
|
|
}
|
|
agentID := parts[0]
|
|
jobID := parts[2]
|
|
|
|
var req struct {
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
|
return
|
|
}
|
|
|
|
if req.Status == "" {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, "Status is required", requestID)
|
|
return
|
|
}
|
|
|
|
if err := h.svc.UpdateJobStatus(agentID, jobID, req.Status, req.Error); err != nil {
|
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update job status", requestID)
|
|
return
|
|
}
|
|
|
|
JSON(w, http.StatusOK, map[string]string{
|
|
"status": "updated",
|
|
})
|
|
}
|