mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 06:38:58 +00:00
feat: M25 post-deployment TLS verification + M26 Traefik/Caddy targets
M25: After deploying a certificate, the agent probes the live TLS
endpoint and compares SHA-256 fingerprints to verify the correct cert
is being served. Best-effort — failures don't block deployments.
New endpoints: POST /jobs/{id}/verify, GET /jobs/{id}/verification.
Migration 000008 adds verification columns to jobs table.
M26: Traefik target connector (file provider, auto-reload) and Caddy
target connector (dual-mode: admin API hot-reload or file-based).
Both wired into agent dispatch.
Also: restructured README to highlight supported integrations (issuers,
targets, notifiers) earlier, moved API/CLI/MCP sections lower. Updated
all docs (features, connectors, architecture, testing guide, why-certctl)
and fixed integration tests for 18-param RegisterHandlers signature.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// VerificationService defines the service interface for verification operations.
|
||||
type VerificationService interface {
|
||||
// RecordVerificationResult records the outcome of TLS endpoint verification.
|
||||
RecordVerificationResult(ctx interface{}, result *domain.VerificationResult) error
|
||||
|
||||
// GetVerificationResult retrieves the verification status for a job.
|
||||
GetVerificationResult(ctx interface{}, jobID string) (*domain.VerificationResult, error)
|
||||
}
|
||||
|
||||
// VerificationHandler handles HTTP requests for certificate deployment verification.
|
||||
type VerificationHandler struct {
|
||||
svc VerificationService
|
||||
}
|
||||
|
||||
// NewVerificationHandler creates a new VerificationHandler.
|
||||
func NewVerificationHandler(svc VerificationService) VerificationHandler {
|
||||
return VerificationHandler{svc: svc}
|
||||
}
|
||||
|
||||
// VerifyDeploymentRequest represents the request body for POST /api/v1/jobs/{id}/verify
|
||||
type VerifyDeploymentRequest struct {
|
||||
TargetID string `json:"target_id"`
|
||||
ExpectedFingerprint string `json:"expected_fingerprint"`
|
||||
ActualFingerprint string `json:"actual_fingerprint"`
|
||||
Verified bool `json:"verified"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyDeployment handles POST /api/v1/jobs/{id}/verify
|
||||
// Agents submit verification results after attempting to probe the live TLS endpoint.
|
||||
// This endpoint records the verification outcome (success or failure) and updates the job status.
|
||||
func (h VerificationHandler) VerifyDeployment(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract job ID from URL path: /api/v1/jobs/{id}/verify
|
||||
jobID, err := extractIDFromPath(r.URL.Path, "/api/v1/jobs/", "/verify")
|
||||
if err != nil || jobID == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid job ID", middleware.GetRequestID(r.Context()))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req VerifyDeploymentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err), middleware.GetRequestID(r.Context()))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.TargetID == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "target_id is required", middleware.GetRequestID(r.Context()))
|
||||
return
|
||||
}
|
||||
if req.ExpectedFingerprint == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "expected_fingerprint is required", middleware.GetRequestID(r.Context()))
|
||||
return
|
||||
}
|
||||
if req.ActualFingerprint == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "actual_fingerprint is required", middleware.GetRequestID(r.Context()))
|
||||
return
|
||||
}
|
||||
|
||||
// Build verification result
|
||||
result := &domain.VerificationResult{
|
||||
JobID: jobID,
|
||||
TargetID: req.TargetID,
|
||||
ExpectedFingerprint: req.ExpectedFingerprint,
|
||||
ActualFingerprint: req.ActualFingerprint,
|
||||
Verified: req.Verified,
|
||||
VerifiedAt: time.Now().UTC(),
|
||||
Error: req.Error,
|
||||
}
|
||||
|
||||
// Record result
|
||||
if err := h.svc.RecordVerificationResult(r.Context(), result); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to record verification result: %v", err), middleware.GetRequestID(r.Context()))
|
||||
return
|
||||
}
|
||||
|
||||
// Return success response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"job_id": jobID,
|
||||
"verified": req.Verified,
|
||||
"verified_at": result.VerifiedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GetVerificationStatus handles GET /api/v1/jobs/{id}/verification
|
||||
// Returns the current verification status for a job.
|
||||
func (h VerificationHandler) GetVerificationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract job ID from URL path: /api/v1/jobs/{id}/verification
|
||||
jobID, err := extractIDFromPath(r.URL.Path, "/api/v1/jobs/", "/verification")
|
||||
if err != nil || jobID == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid job ID", middleware.GetRequestID(r.Context()))
|
||||
return
|
||||
}
|
||||
|
||||
// Get verification result
|
||||
result, err := h.svc.GetVerificationResult(r.Context(), jobID)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get verification result: %v", err), middleware.GetRequestID(r.Context()))
|
||||
return
|
||||
}
|
||||
|
||||
// Return result
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// extractIDFromPath extracts the resource ID from a path like /api/v1/jobs/{id}/verify
|
||||
// prefix: "/api/v1/jobs/" suffix: "/verify"
|
||||
// Returns the extracted ID between prefix and suffix.
|
||||
func extractIDFromPath(path, prefix, suffix string) (string, error) {
|
||||
if len(path) <= len(prefix)+len(suffix) {
|
||||
return "", fmt.Errorf("path too short")
|
||||
}
|
||||
if !HasPrefix(path, prefix) {
|
||||
return "", fmt.Errorf("path does not start with prefix")
|
||||
}
|
||||
// Remove prefix
|
||||
remainder := path[len(prefix):]
|
||||
// Find suffix
|
||||
idx := FindLastOccurrence(remainder, suffix)
|
||||
if idx == -1 {
|
||||
return "", fmt.Errorf("suffix not found")
|
||||
}
|
||||
return remainder[:idx], nil
|
||||
}
|
||||
|
||||
// HasPrefix checks if a string starts with a prefix.
|
||||
func HasPrefix(s, prefix string) bool {
|
||||
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
||||
}
|
||||
|
||||
// FindLastOccurrence finds the last occurrence of a substring (simplified version).
|
||||
func FindLastOccurrence(s, substr string) int {
|
||||
if len(substr) == 0 {
|
||||
return len(s)
|
||||
}
|
||||
for i := len(s) - len(substr); i >= 0; i-- {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// mockVerificationService is a test double for VerificationService.
|
||||
type mockVerificationService struct {
|
||||
recordErr error
|
||||
getErr error
|
||||
results map[string]*domain.VerificationResult
|
||||
}
|
||||
|
||||
func (m *mockVerificationService) RecordVerificationResult(ctx interface{}, result *domain.VerificationResult) error {
|
||||
if m.recordErr != nil {
|
||||
return m.recordErr
|
||||
}
|
||||
if m.results == nil {
|
||||
m.results = make(map[string]*domain.VerificationResult)
|
||||
}
|
||||
m.results[result.JobID] = result
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockVerificationService) GetVerificationResult(ctx interface{}, jobID string) (*domain.VerificationResult, error) {
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
if m.results == nil {
|
||||
m.results = make(map[string]*domain.VerificationResult)
|
||||
}
|
||||
return m.results[jobID], nil
|
||||
}
|
||||
|
||||
func TestVerifyDeployment_Success(t *testing.T) {
|
||||
mockSvc := &mockVerificationService{
|
||||
results: make(map[string]*domain.VerificationResult),
|
||||
}
|
||||
handler := NewVerificationHandler(mockSvc)
|
||||
|
||||
req := VerifyDeploymentRequest{
|
||||
TargetID: "t-nginx1",
|
||||
ExpectedFingerprint: "abc123",
|
||||
ActualFingerprint: "abc123",
|
||||
Verified: true,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test1/verify", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.VerifyDeployment(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify result was recorded
|
||||
result := mockSvc.results["j-test1"]
|
||||
if result == nil {
|
||||
t.Error("expected verification result to be recorded")
|
||||
}
|
||||
if !result.Verified {
|
||||
t.Error("expected Verified to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDeployment_FingerPrintMismatch(t *testing.T) {
|
||||
mockSvc := &mockVerificationService{
|
||||
results: make(map[string]*domain.VerificationResult),
|
||||
}
|
||||
handler := NewVerificationHandler(mockSvc)
|
||||
|
||||
req := VerifyDeploymentRequest{
|
||||
TargetID: "t-apache1",
|
||||
ExpectedFingerprint: "aaa111",
|
||||
ActualFingerprint: "bbb222",
|
||||
Verified: false,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test2/verify", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.VerifyDeployment(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
result := mockSvc.results["j-test2"]
|
||||
if result == nil {
|
||||
t.Error("expected verification result to be recorded")
|
||||
}
|
||||
if result.Verified {
|
||||
t.Error("expected Verified to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDeployment_MissingTargetID(t *testing.T) {
|
||||
mockSvc := &mockVerificationService{}
|
||||
handler := NewVerificationHandler(mockSvc)
|
||||
|
||||
req := VerifyDeploymentRequest{
|
||||
ExpectedFingerprint: "abc123",
|
||||
ActualFingerprint: "abc123",
|
||||
Verified: true,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test3/verify", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.VerifyDeployment(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDeployment_MissingExpectedFingerprint(t *testing.T) {
|
||||
mockSvc := &mockVerificationService{}
|
||||
handler := NewVerificationHandler(mockSvc)
|
||||
|
||||
req := VerifyDeploymentRequest{
|
||||
TargetID: "t-nginx1",
|
||||
ActualFingerprint: "abc123",
|
||||
Verified: true,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test4/verify", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.VerifyDeployment(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDeployment_InvalidMethod(t *testing.T) {
|
||||
mockSvc := &mockVerificationService{}
|
||||
handler := NewVerificationHandler(mockSvc)
|
||||
|
||||
httpReq := httptest.NewRequest("GET", "/api/v1/jobs/j-test5/verify", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.VerifyDeployment(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDeployment_InvalidJSON(t *testing.T) {
|
||||
mockSvc := &mockVerificationService{}
|
||||
handler := NewVerificationHandler(mockSvc)
|
||||
|
||||
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test6/verify", bytes.NewBufferString("invalid json"))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.VerifyDeployment(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVerificationStatus_Success(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
fp := "xyz789"
|
||||
mockSvc := &mockVerificationService{
|
||||
results: map[string]*domain.VerificationResult{
|
||||
"j-test7": {
|
||||
JobID: "j-test7",
|
||||
TargetID: "t-haproxy1",
|
||||
ExpectedFingerprint: "xyz789",
|
||||
ActualFingerprint: fp,
|
||||
Verified: true,
|
||||
VerifiedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := NewVerificationHandler(mockSvc)
|
||||
|
||||
httpReq := httptest.NewRequest("GET", "/api/v1/jobs/j-test7/verification", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetVerificationStatus(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result domain.VerificationResult
|
||||
json.NewDecoder(w.Body).Decode(&result)
|
||||
if result.JobID != "j-test7" {
|
||||
t.Errorf("expected job ID j-test7, got %s", result.JobID)
|
||||
}
|
||||
if !result.Verified {
|
||||
t.Error("expected Verified to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVerificationStatus_InvalidMethod(t *testing.T) {
|
||||
mockSvc := &mockVerificationService{}
|
||||
handler := NewVerificationHandler(mockSvc)
|
||||
|
||||
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test8/verification", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetVerificationStatus(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDeployment_ServiceError(t *testing.T) {
|
||||
mockSvc := &mockVerificationService{
|
||||
recordErr: ErrServiceUnavailable,
|
||||
}
|
||||
handler := NewVerificationHandler(mockSvc)
|
||||
|
||||
req := VerifyDeploymentRequest{
|
||||
TargetID: "t-nginx1",
|
||||
ExpectedFingerprint: "abc123",
|
||||
ActualFingerprint: "abc123",
|
||||
Verified: true,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test9/verify", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.VerifyDeployment(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
var ErrServiceUnavailable = NewServiceError("service unavailable")
|
||||
|
||||
func NewServiceError(msg string) error {
|
||||
return &serviceError{msg: msg}
|
||||
}
|
||||
|
||||
type serviceError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *serviceError) Error() string {
|
||||
return e.msg
|
||||
}
|
||||
Reference in New Issue
Block a user