mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 14:38:52 +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
|
||||
}
|
||||
@@ -62,6 +62,7 @@ func (r *Router) RegisterHandlers(
|
||||
health handler.HealthHandler,
|
||||
discovery handler.DiscoveryHandler,
|
||||
networkScan handler.NetworkScanHandler,
|
||||
verification handler.VerificationHandler,
|
||||
) {
|
||||
// Health endpoints (no auth middleware — must always be accessible)
|
||||
r.mux.Handle("GET /health", middleware.Chain(
|
||||
@@ -207,6 +208,10 @@ func (r *Router) RegisterHandlers(
|
||||
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(networkScan.UpdateNetworkScanTarget))
|
||||
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(networkScan.DeleteNetworkScanTarget))
|
||||
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(networkScan.TriggerNetworkScan))
|
||||
|
||||
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
|
||||
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(verification.VerifyDeployment))
|
||||
r.Register("GET /api/v1/jobs/{id}/verification", http.HandlerFunc(verification.GetVerificationStatus))
|
||||
}
|
||||
|
||||
// RegisterESTHandlers sets up EST (RFC 7030) routes under /.well-known/est/.
|
||||
|
||||
@@ -23,6 +23,7 @@ type Config struct {
|
||||
Notifiers NotifierConfig
|
||||
NetworkScan NetworkScanConfig
|
||||
EST ESTConfig
|
||||
Verification VerificationConfig
|
||||
}
|
||||
|
||||
// NotifierConfig contains configuration for notification connectors.
|
||||
@@ -97,6 +98,13 @@ type NetworkScanConfig struct {
|
||||
ScanInterval time.Duration // How often to run network scans (default 6h)
|
||||
}
|
||||
|
||||
// VerificationConfig controls post-deployment TLS verification behavior.
|
||||
type VerificationConfig struct {
|
||||
Enabled bool // Enable verification (default true)
|
||||
Timeout time.Duration // Timeout for TLS probe (default 10s)
|
||||
Delay time.Duration // Wait before verification after deployment (default 2s)
|
||||
}
|
||||
|
||||
// ServerConfig contains HTTP server configuration.
|
||||
type ServerConfig struct {
|
||||
Host string
|
||||
@@ -204,6 +212,11 @@ func Load() (*Config, error) {
|
||||
IssuerID: getEnv("CERTCTL_EST_ISSUER_ID", "iss-local"),
|
||||
ProfileID: getEnv("CERTCTL_EST_PROFILE_ID", ""),
|
||||
},
|
||||
Verification: VerificationConfig{
|
||||
Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true),
|
||||
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
|
||||
Delay: getEnvDuration("CERTCTL_VERIFY_DELAY", 2*time.Second),
|
||||
},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Config represents the Caddy deployment target configuration.
|
||||
// Caddy supports both API-based and file-based certificate deployment.
|
||||
// In API mode, certificates are posted to the Caddy admin API.
|
||||
// In file mode, certificates are written to a directory and Caddy reloads.
|
||||
type Config struct {
|
||||
AdminAPI string `json:"admin_api"` // Caddy admin API URL (e.g., http://localhost:2019, default: http://localhost:2019)
|
||||
CertDir string `json:"cert_dir"` // Directory for file-based deployment (used if API fails or mode=file)
|
||||
CertFile string `json:"cert_file"` // Filename for certificate in file mode (default: cert.pem)
|
||||
KeyFile string `json:"key_file"` // Filename for private key in file mode (default: key.pem)
|
||||
Mode string `json:"mode"` // Deployment mode: "api" (default) or "file"
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Caddy servers.
|
||||
// This connector runs on the AGENT side and handles local certificate deployment.
|
||||
// It supports both API-based hot reload and file-based deployment.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// New creates a new Caddy 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: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the Caddy configuration is valid.
|
||||
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 Caddy config: %w", err)
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if cfg.AdminAPI == "" {
|
||||
cfg.AdminAPI = "http://localhost:2019"
|
||||
}
|
||||
if cfg.Mode == "" {
|
||||
cfg.Mode = "api"
|
||||
}
|
||||
if cfg.CertFile == "" {
|
||||
cfg.CertFile = "cert.pem"
|
||||
}
|
||||
if cfg.KeyFile == "" {
|
||||
cfg.KeyFile = "key.pem"
|
||||
}
|
||||
|
||||
// Validate mode
|
||||
if cfg.Mode != "api" && cfg.Mode != "file" {
|
||||
return fmt.Errorf("Caddy mode must be 'api' or 'file', got: %s", cfg.Mode)
|
||||
}
|
||||
|
||||
c.logger.Info("validating Caddy configuration",
|
||||
"admin_api", cfg.AdminAPI,
|
||||
"mode", cfg.Mode)
|
||||
|
||||
// For file mode, verify directory exists
|
||||
if cfg.Mode == "file" {
|
||||
if cfg.CertDir == "" {
|
||||
return fmt.Errorf("Caddy cert_dir is required in file mode")
|
||||
}
|
||||
if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("Caddy cert directory does not exist: %s", cfg.CertDir)
|
||||
}
|
||||
// Test write access
|
||||
testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
return fmt.Errorf("Caddy cert directory is not writable: %s (%w)", cfg.CertDir, err)
|
||||
}
|
||||
os.Remove(testFile)
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("Caddy configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate deploys a certificate to Caddy using the configured mode.
|
||||
// In API mode, it posts the certificate to Caddy's admin API.
|
||||
// In file mode, it writes the certificate files and relies on Caddy's file watcher.
|
||||
//
|
||||
// Steps:
|
||||
// 1. If mode="api": POST to Caddy admin API endpoint with certificate data
|
||||
// 2. If mode="file" or API fails: Write certificate and key files to cert_dir
|
||||
// 3. Log deployment status
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to Caddy",
|
||||
"mode", c.config.Mode,
|
||||
"admin_api", c.config.AdminAPI)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Try API mode if configured
|
||||
if c.config.Mode == "api" {
|
||||
result, err := c.deployViaAPI(ctx, request)
|
||||
if err == nil {
|
||||
c.logger.Info("certificate deployed to Caddy via API",
|
||||
"duration", time.Since(startTime).String())
|
||||
return result, nil
|
||||
}
|
||||
c.logger.Warn("API deployment failed, falling back to file mode", "error", err)
|
||||
}
|
||||
|
||||
// Fall back to file mode
|
||||
return c.deployViaFile(ctx, request, startTime)
|
||||
}
|
||||
|
||||
// deployViaAPI deploys a certificate using Caddy's admin API.
|
||||
func (c *Connector) deployViaAPI(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Debug("attempting API deployment", "url", c.config.AdminAPI)
|
||||
|
||||
// Build the certificate payload with combined cert and chain
|
||||
certData := request.CertPEM + "\n"
|
||||
if request.ChainPEM != "" {
|
||||
certData += request.ChainPEM + "\n"
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"cert": certData,
|
||||
"key": request.KeyPEM,
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(payload)
|
||||
apiURL := c.config.AdminAPI + "/config/apps/tls/certificates/load"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create API request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reach Caddy API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return nil, fmt.Errorf("Caddy API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: c.config.AdminAPI,
|
||||
DeploymentID: fmt.Sprintf("caddy-api-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployed via Caddy admin API",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"method": "api",
|
||||
"admin_url": c.config.AdminAPI,
|
||||
"duration_ms": fmt.Sprintf("%d", time.Since(time.Now()).Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// deployViaFile deploys a certificate by writing files to the cert directory.
|
||||
func (c *Connector) deployViaFile(ctx context.Context, request target.DeploymentRequest, startTime time.Time) (*target.DeploymentResult, error) {
|
||||
c.logger.Debug("deploying via file mode", "cert_dir", c.config.CertDir)
|
||||
|
||||
if c.config.CertDir == "" {
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: "cert_dir required for file mode deployment",
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("cert_dir not configured for file mode")
|
||||
}
|
||||
|
||||
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
|
||||
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
|
||||
|
||||
// Write certificate with chain
|
||||
certData := request.CertPEM + "\n"
|
||||
if request.ChainPEM != "" {
|
||||
certData += request.ChainPEM + "\n"
|
||||
}
|
||||
if err := os.WriteFile(certPath, []byte(certData), 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: certPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Write private key
|
||||
if request.KeyPEM != "" {
|
||||
if err := os.WriteFile(keyPath, []byte(request.KeyPEM), 0600); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write private key: %v", err)
|
||||
c.logger.Error("key deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: keyPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
c.logger.Info("certificate deployed to Caddy via file mode",
|
||||
"duration", deploymentDuration.String(),
|
||||
"cert_path", certPath,
|
||||
"key_path", keyPath)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: certPath,
|
||||
DeploymentID: fmt.Sprintf("caddy-file-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployed to Caddy (file-based)",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"method": "file",
|
||||
"cert_path": certPath,
|
||||
"key_path": keyPath,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
|
||||
// For API mode, it doesn't perform additional validation.
|
||||
// For file mode, it checks that the certificate and key files exist and are readable.
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating Caddy deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial,
|
||||
"mode", c.config.Mode)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// For file mode, verify files exist
|
||||
if c.config.Mode == "file" || c.config.CertDir != "" {
|
||||
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
|
||||
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
|
||||
|
||||
if _, err := os.Stat(certPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("certificate file not found: %s", certPath)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: certPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("private key file not found: %s", keyPath)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: keyPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
validationDuration := time.Since(startTime)
|
||||
c.logger.Info("Caddy deployment validated successfully",
|
||||
"duration", validationDuration.String())
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.AdminAPI,
|
||||
Message: "Caddy certificate deployment validated",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"mode": c.config.Mode,
|
||||
"admin_api": c.config.AdminAPI,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package caddy_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
||||
)
|
||||
|
||||
func TestCaddyConnector_ValidateConfig_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := caddy.Config{
|
||||
AdminAPI: "http://localhost:2019",
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
Mode: "file",
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_ValidateConfig_InvalidJSON(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
connector := caddy.New(&caddy.Config{}, logger)
|
||||
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_ValidateConfig_InvalidMode(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := caddy.Config{
|
||||
AdminAPI: "http://localhost:2019",
|
||||
CertDir: tmpDir,
|
||||
Mode: "invalid",
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_ValidateConfig_FileMode_MissingCertDir(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := caddy.Config{
|
||||
AdminAPI: "http://localhost:2019",
|
||||
Mode: "file",
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert_dir in file mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_ValidateConfig_DefaultsApplied(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := caddy.Config{
|
||||
CertDir: tmpDir,
|
||||
Mode: "file",
|
||||
// Don't specify AdminAPI, CertFile, KeyFile - should use defaults
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_DeployViaAPI_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a mock Caddy admin API server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/config/apps/tls/certificates/load") {
|
||||
// Verify POST request with JSON body
|
||||
if r.Method != "POST" {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]string
|
||||
json.Unmarshal(body, &payload)
|
||||
if payload["cert"] == "" {
|
||||
t.Fatal("cert field missing in payload")
|
||||
}
|
||||
if payload["key"] == "" {
|
||||
t.Fatal("key field missing in payload")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := caddy.Config{
|
||||
AdminAPI: server.URL,
|
||||
Mode: "api",
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.Message, "API") {
|
||||
t.Fatalf("expected API deployment message, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_DeployViaAPI_ServerError(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a mock Caddy admin API server that returns error
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid certificate"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := caddy.Config{
|
||||
AdminAPI: server.URL,
|
||||
CertDir: tmpDir,
|
||||
Mode: "api",
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
// API fails and falls back to file mode - should succeed
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed via file fallback, got: %s", result.Message)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.Message, "file") {
|
||||
t.Fatalf("expected file deployment message after API failure, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_DeployViaFile_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := caddy.Config{
|
||||
AdminAPI: "http://localhost:2019",
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
Mode: "file",
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify files were created
|
||||
certPath := filepath.Join(tmpDir, "cert.pem")
|
||||
keyPath := filepath.Join(tmpDir, "key.pem")
|
||||
|
||||
if _, err := os.Stat(certPath); os.IsNotExist(err) {
|
||||
t.Fatalf("certificate file was not created: %s", certPath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||
t.Fatalf("key file was not created: %s", keyPath)
|
||||
}
|
||||
|
||||
// Verify key file has correct permissions
|
||||
keyInfo, _ := os.Stat(keyPath)
|
||||
if keyInfo.Mode().Perm() != 0600 {
|
||||
t.Fatalf("key file permissions are %o, expected 0600", keyInfo.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_DeployViaFile_WriteError(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := caddy.Config{
|
||||
AdminAPI: "http://localhost:2019",
|
||||
CertDir: "/root/nonexistent",
|
||||
Mode: "file",
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for write failure")
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
t.Fatal("deployment should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_ValidateDeployment_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := caddy.Config{
|
||||
AdminAPI: "http://localhost:2019",
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
Mode: "file",
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
// Deploy a certificate
|
||||
deployRequest := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
connector.DeployCertificate(ctx, deployRequest)
|
||||
|
||||
// Validate deployment
|
||||
validateRequest := target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
}
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, validateRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
t.Fatalf("validation should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
if result.Serial != "123456" {
|
||||
t.Fatalf("serial mismatch: expected 123456, got %s", result.Serial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_ValidateDeployment_FileNotFound(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := caddy.Config{
|
||||
AdminAPI: "http://localhost:2019",
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
Mode: "file",
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
// Don't deploy, just validate
|
||||
validateRequest := target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
}
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, validateRequest)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing certificate file")
|
||||
}
|
||||
|
||||
if result.Valid {
|
||||
t.Fatal("validation should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyConnector_APIMode_NoChain(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/config/apps/tls/certificates/load") {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := caddy.Config{
|
||||
AdminAPI: server.URL,
|
||||
Mode: "api",
|
||||
}
|
||||
|
||||
connector := caddy.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
// No ChainPEM
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package traefik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Config represents the Traefik deployment target configuration.
|
||||
// Traefik uses a file provider that watches a directory for certificate files.
|
||||
// When files change, Traefik automatically reloads without requiring a reload command.
|
||||
type Config struct {
|
||||
CertDir string `json:"cert_dir"` // Directory where Traefik watches for certificate files
|
||||
CertFile string `json:"cert_file"` // Filename for certificate (default: cert.pem)
|
||||
KeyFile string `json:"key_file"` // Filename for private key (default: key.pem)
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Traefik servers.
|
||||
// This connector runs on the AGENT side and handles local certificate deployment.
|
||||
// Traefik watches the configured directory and automatically reloads when files change.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Traefik 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 certificate directory exists and is writable.
|
||||
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 Traefik config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.CertDir == "" {
|
||||
return fmt.Errorf("Traefik cert_dir is required")
|
||||
}
|
||||
|
||||
// Default filenames if not provided
|
||||
if cfg.CertFile == "" {
|
||||
cfg.CertFile = "cert.pem"
|
||||
}
|
||||
if cfg.KeyFile == "" {
|
||||
cfg.KeyFile = "key.pem"
|
||||
}
|
||||
|
||||
c.logger.Info("validating Traefik configuration",
|
||||
"cert_dir", cfg.CertDir,
|
||||
"cert_file", cfg.CertFile,
|
||||
"key_file", cfg.KeyFile)
|
||||
|
||||
// Verify directory exists and is writable
|
||||
if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("Traefik cert directory does not exist: %s", cfg.CertDir)
|
||||
}
|
||||
|
||||
// Try to write a test file to verify directory is writable
|
||||
testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
return fmt.Errorf("Traefik cert directory is not writable: %s (%w)", cfg.CertDir, err)
|
||||
}
|
||||
// Clean up test file
|
||||
os.Remove(testFile)
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("Traefik configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate writes the certificate and key files to the configured directory.
|
||||
// Traefik watches this directory and automatically reloads when files change.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Write certificate to cert_file with mode 0644 (readable by all)
|
||||
// 2. Write private key to key_file with mode 0600 (private key permissions)
|
||||
// 3. Traefik's file watcher automatically picks up the changes
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to Traefik",
|
||||
"cert_dir", c.config.CertDir,
|
||||
"cert_file", c.config.CertFile,
|
||||
"key_file", c.config.KeyFile)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
|
||||
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
|
||||
|
||||
// Write certificate and chain combined with mode 0644 (readable by all)
|
||||
certData := request.CertPEM + "\n"
|
||||
if request.ChainPEM != "" {
|
||||
certData += request.ChainPEM + "\n"
|
||||
}
|
||||
if err := os.WriteFile(certPath, []byte(certData), 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: certPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Write private key with secure permissions (0600: rw-------)
|
||||
if request.KeyPEM != "" {
|
||||
if err := os.WriteFile(keyPath, []byte(request.KeyPEM), 0600); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write private key: %v", err)
|
||||
c.logger.Error("key deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: keyPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
c.logger.Info("certificate deployed to Traefik successfully",
|
||||
"duration", deploymentDuration.String(),
|
||||
"cert_path", certPath,
|
||||
"key_path", keyPath)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: certPath,
|
||||
DeploymentID: fmt.Sprintf("traefik-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployed to Traefik (file watcher will auto-reload)",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"cert_path": certPath,
|
||||
"key_path": keyPath,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the deployed certificate files are readable.
|
||||
// It checks that both the certificate and key files exist and are accessible.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Verify certificate file exists and is readable
|
||||
// 2. Verify key file exists and is readable
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating Traefik deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
|
||||
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
|
||||
|
||||
// Verify certificate file exists and is readable
|
||||
if _, err := os.Stat(certPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("certificate file not found: %s", certPath)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: certPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Verify key file exists and is readable
|
||||
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("private key file not found: %s", keyPath)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: keyPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
validationDuration := time.Since(startTime)
|
||||
c.logger.Info("Traefik deployment validated successfully",
|
||||
"duration", validationDuration.String())
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: certPath,
|
||||
Message: "Certificate and key files accessible",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"cert_path": certPath,
|
||||
"key_path": keyPath,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package traefik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/traefik"
|
||||
)
|
||||
|
||||
func TestTraefikConnector_ValidateConfig_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_ValidateConfig_InvalidJSON(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
connector := traefik.New(&traefik.Config{}, logger)
|
||||
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_ValidateConfig_MissingCertDir(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := traefik.Config{
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert_dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_ValidateConfig_DirectoryNotExists(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
cfg := traefik.Config{
|
||||
CertDir: "/nonexistent/directory",
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_DeployCertificate_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify certificate file was created
|
||||
certPath := filepath.Join(tmpDir, "cert.pem")
|
||||
if _, err := os.Stat(certPath); os.IsNotExist(err) {
|
||||
t.Fatalf("certificate file was not created: %s", certPath)
|
||||
}
|
||||
|
||||
// Verify key file was created with correct permissions
|
||||
keyPath := filepath.Join(tmpDir, "key.pem")
|
||||
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||
t.Fatalf("key file was not created: %s", keyPath)
|
||||
}
|
||||
|
||||
// Check key file permissions (should be 0600)
|
||||
keyInfo, _ := os.Stat(keyPath)
|
||||
perms := keyInfo.Mode().Perm()
|
||||
if perms != 0600 {
|
||||
t.Fatalf("key file permissions are %o, expected 0600", perms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_DeployCertificate_WriteError(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
// Use a non-existent directory to trigger write error
|
||||
cfg := traefik.Config{
|
||||
CertDir: "/root/certctl/certs",
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for write failure")
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
t.Fatal("deployment should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_ValidateDeployment_Success(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
// First deploy a certificate
|
||||
deployRequest := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
}
|
||||
connector.DeployCertificate(ctx, deployRequest)
|
||||
|
||||
// Now validate
|
||||
validateRequest := target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
}
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, validateRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Valid {
|
||||
t.Fatalf("validation should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
if result.Serial != "123456" {
|
||||
t.Fatalf("serial mismatch: expected 123456, got %s", result.Serial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_ValidateDeployment_CertFileNotFound(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
// Don't deploy anything, just validate
|
||||
validateRequest := target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
}
|
||||
|
||||
result, err := connector.ValidateDeployment(ctx, validateRequest)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing certificate file")
|
||||
}
|
||||
|
||||
if result.Valid {
|
||||
t.Fatal("validation should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_DeployCertificate_WithoutChain(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
CertFile: "cert.pem",
|
||||
KeyFile: "key.pem",
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
_ = connector.ValidateConfig(ctx, rawConfig)
|
||||
|
||||
// Deploy without chain
|
||||
request := target.DeploymentRequest{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
|
||||
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("deployment should succeed, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify certificate file exists
|
||||
certPath := filepath.Join(tmpDir, "cert.pem")
|
||||
data, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read cert file: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----\n" {
|
||||
t.Fatalf("certificate content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraefikConnector_DefaultFilenames(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
cfg := traefik.Config{
|
||||
CertDir: tmpDir,
|
||||
// Don't specify CertFile and KeyFile, use defaults
|
||||
}
|
||||
|
||||
connector := traefik.New(&cfg, logger)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -75,9 +75,11 @@ const (
|
||||
type TargetType string
|
||||
|
||||
const (
|
||||
TargetTypeNGINX TargetType = "NGINX"
|
||||
TargetTypeApache TargetType = "Apache"
|
||||
TargetTypeHAProxy TargetType = "HAProxy"
|
||||
TargetTypeF5 TargetType = "F5"
|
||||
TargetTypeIIS TargetType = "IIS"
|
||||
TargetTypeNGINX TargetType = "NGINX"
|
||||
TargetTypeApache TargetType = "Apache"
|
||||
TargetTypeHAProxy TargetType = "HAProxy"
|
||||
TargetTypeF5 TargetType = "F5"
|
||||
TargetTypeIIS TargetType = "IIS"
|
||||
TargetTypeTraefik TargetType = "Traefik"
|
||||
TargetTypeCaddy TargetType = "Caddy"
|
||||
)
|
||||
|
||||
+16
-12
@@ -7,18 +7,22 @@ import (
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
VerificationStatus VerificationStatus `json:"verification_status"`
|
||||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||
VerificationError *string `json:"verification_error,omitempty"`
|
||||
VerificationFp *string `json:"verification_fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
// JobType represents the classification of work to be performed.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// VerificationStatus represents the status of certificate deployment verification.
|
||||
type VerificationStatus string
|
||||
|
||||
const (
|
||||
// VerificationPending: verification has not yet been performed.
|
||||
VerificationPending VerificationStatus = "pending"
|
||||
// VerificationSuccess: the live TLS endpoint serves the expected certificate.
|
||||
VerificationSuccess VerificationStatus = "success"
|
||||
// VerificationFailed: the live TLS endpoint does not serve the expected certificate.
|
||||
VerificationFailed VerificationStatus = "failed"
|
||||
// VerificationSkipped: verification was skipped (disabled or not applicable).
|
||||
VerificationSkipped VerificationStatus = "skipped"
|
||||
)
|
||||
|
||||
// VerificationResult represents the outcome of verifying a deployed certificate
|
||||
// against the live TLS endpoint it should be serving.
|
||||
type VerificationResult struct {
|
||||
// JobID is the ID of the deployment job being verified.
|
||||
JobID string `json:"job_id"`
|
||||
// TargetID is the ID of the deployment target.
|
||||
TargetID string `json:"target_id"`
|
||||
// ExpectedFingerprint is the SHA-256 fingerprint of the certificate that was deployed.
|
||||
ExpectedFingerprint string `json:"expected_fingerprint"`
|
||||
// ActualFingerprint is the SHA-256 fingerprint of the certificate currently being served
|
||||
// at the live TLS endpoint.
|
||||
ActualFingerprint string `json:"actual_fingerprint"`
|
||||
// Verified is true if expected and actual fingerprints match.
|
||||
Verified bool `json:"verified"`
|
||||
// VerifiedAt is the timestamp when verification was performed.
|
||||
VerifiedAt time.Time `json:"verified_at"`
|
||||
// Error is a non-empty error message if verification failed to complete.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVerificationStatus_Constants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status VerificationStatus
|
||||
expected string
|
||||
}{
|
||||
{"Pending", VerificationPending, "pending"},
|
||||
{"Success", VerificationSuccess, "success"},
|
||||
{"Failed", VerificationFailed, "failed"},
|
||||
{"Skipped", VerificationSkipped, "skipped"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.status) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, string(tt.status))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationResult_Marshaling(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
result := &VerificationResult{
|
||||
JobID: "j-test123",
|
||||
TargetID: "t-nginx1",
|
||||
ExpectedFingerprint: "abc123def456",
|
||||
ActualFingerprint: "abc123def456",
|
||||
Verified: true,
|
||||
VerifiedAt: now,
|
||||
Error: "",
|
||||
}
|
||||
|
||||
if result.JobID != "j-test123" {
|
||||
t.Errorf("JobID mismatch: got %s", result.JobID)
|
||||
}
|
||||
if !result.Verified {
|
||||
t.Error("expected Verified to be true")
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Errorf("expected no error, got %s", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationResult_WithError(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
result := &VerificationResult{
|
||||
JobID: "j-test456",
|
||||
TargetID: "t-apache1",
|
||||
ExpectedFingerprint: "aaa111bbb222",
|
||||
ActualFingerprint: "ccc333ddd444",
|
||||
Verified: false,
|
||||
VerifiedAt: now,
|
||||
Error: "connection timeout",
|
||||
}
|
||||
|
||||
if result.Verified {
|
||||
t.Error("expected Verified to be false")
|
||||
}
|
||||
if result.Error != "connection timeout" {
|
||||
t.Errorf("expected error message, got %s", result.Error)
|
||||
}
|
||||
if result.ExpectedFingerprint == result.ActualFingerprint {
|
||||
t.Error("expected fingerprints to differ")
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
healthHandler := handler.NewHealthHandler("none")
|
||||
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
||||
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
||||
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
||||
|
||||
// EST handler — uses real Local CA issuer via ESTService
|
||||
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
|
||||
@@ -106,6 +107,7 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
healthHandler,
|
||||
discoveryHandler,
|
||||
networkScanHandler,
|
||||
verificationHandler,
|
||||
)
|
||||
r.RegisterESTHandlers(estHandler)
|
||||
|
||||
@@ -1208,3 +1210,14 @@ func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) er
|
||||
func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// mockVerificationService implements handler.VerificationService for integration tests.
|
||||
type mockVerificationService struct{}
|
||||
|
||||
func (m *mockVerificationService) RecordVerificationResult(ctx interface{}, result *domain.VerificationResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockVerificationService) GetVerificationResult(ctx interface{}, jobID string) (*domain.VerificationResult, error) {
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
healthHandler := handler.NewHealthHandler("none")
|
||||
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
||||
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
||||
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
||||
|
||||
// EST handler — uses real Local CA issuer via ESTService
|
||||
estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger)
|
||||
@@ -98,6 +99,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository
|
||||
healthHandler,
|
||||
discoveryHandler,
|
||||
networkScanHandler,
|
||||
verificationHandler,
|
||||
)
|
||||
r.RegisterESTHandlers(estHandler)
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// VerificationService handles recording and querying certificate deployment verification results.
|
||||
type VerificationService struct {
|
||||
jobRepo repository.JobRepository
|
||||
auditService *AuditService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewVerificationService creates a new verification service.
|
||||
func NewVerificationService(
|
||||
jobRepo repository.JobRepository,
|
||||
auditService *AuditService,
|
||||
logger *slog.Logger,
|
||||
) *VerificationService {
|
||||
return &VerificationService{
|
||||
jobRepo: jobRepo,
|
||||
auditService: auditService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// RecordVerificationResult updates a job with the results of TLS endpoint verification.
|
||||
// This records both success and failure results, along with timestamp and fingerprint comparison.
|
||||
// An audit event is recorded for every verification result.
|
||||
func (s *VerificationService) RecordVerificationResult(ctx context.Context, result *domain.VerificationResult) error {
|
||||
if result == nil {
|
||||
return fmt.Errorf("verification result is required")
|
||||
}
|
||||
if result.JobID == "" {
|
||||
return fmt.Errorf("job ID is required")
|
||||
}
|
||||
|
||||
// Get the current job to update it
|
||||
job, err := s.jobRepo.Get(ctx, result.JobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch job for verification: %w", err)
|
||||
}
|
||||
|
||||
// Determine verification status
|
||||
var status domain.VerificationStatus
|
||||
if result.Error != "" {
|
||||
status = domain.VerificationFailed
|
||||
} else if result.Verified {
|
||||
status = domain.VerificationSuccess
|
||||
} else {
|
||||
status = domain.VerificationFailed
|
||||
}
|
||||
|
||||
// Update job with verification results
|
||||
job.VerificationStatus = status
|
||||
job.VerifiedAt = &result.VerifiedAt
|
||||
job.VerificationFp = &result.ActualFingerprint
|
||||
if result.Error != "" {
|
||||
job.VerificationError = &result.Error
|
||||
}
|
||||
|
||||
if err := s.jobRepo.Update(ctx, job); err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Error("failed to record verification result",
|
||||
"job_id", result.JobID,
|
||||
"error", err)
|
||||
}
|
||||
return fmt.Errorf("failed to update job with verification result: %w", err)
|
||||
}
|
||||
|
||||
// Record audit event
|
||||
auditEvent := "job_verification_success"
|
||||
auditDetails := map[string]interface{}{
|
||||
"job_id": result.JobID,
|
||||
"target_id": result.TargetID,
|
||||
"expected_fingerprint": result.ExpectedFingerprint,
|
||||
"actual_fingerprint": result.ActualFingerprint,
|
||||
"verified": result.Verified,
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
auditEvent = "job_verification_failed"
|
||||
auditDetails["error"] = result.Error
|
||||
}
|
||||
|
||||
s.auditService.RecordEvent(ctx, "agent", domain.ActorTypeAgent,
|
||||
auditEvent, "job", result.JobID,
|
||||
auditDetails)
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.Info("recorded verification result",
|
||||
"job_id", result.JobID,
|
||||
"status", status,
|
||||
"verified", result.Verified)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVerificationResult retrieves the verification status and details for a job.
|
||||
func (s *VerificationService) GetVerificationResult(ctx context.Context, jobID string) (*domain.VerificationResult, error) {
|
||||
if jobID == "" {
|
||||
return nil, fmt.Errorf("job ID is required")
|
||||
}
|
||||
|
||||
job, err := s.jobRepo.Get(ctx, jobID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch job: %w", err)
|
||||
}
|
||||
|
||||
result := &domain.VerificationResult{
|
||||
JobID: job.ID,
|
||||
Verified: job.VerificationStatus == domain.VerificationSuccess,
|
||||
}
|
||||
|
||||
// If target ID is set, populate it
|
||||
if job.TargetID != nil {
|
||||
result.TargetID = *job.TargetID
|
||||
}
|
||||
|
||||
// Populate fingerprints if available
|
||||
if job.VerificationFp != nil {
|
||||
result.ActualFingerprint = *job.VerificationFp
|
||||
}
|
||||
if job.VerificationError != nil {
|
||||
result.Error = *job.VerificationError
|
||||
}
|
||||
if job.VerifiedAt != nil {
|
||||
result.VerifiedAt = *job.VerifiedAt
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// mockJobRepository is a test double for JobRepository.
|
||||
type mockJobRepository struct {
|
||||
jobs map[string]*domain.Job
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockJobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
job, ok := m.jobs[id]
|
||||
if !ok {
|
||||
return nil, errors.New("job not found")
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepository) Create(ctx context.Context, job *domain.Job) error {
|
||||
m.jobs[job.ID] = job
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepository) Update(ctx context.Context, job *domain.Job) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.jobs[job.ID] = job
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockJobRepository) List(ctx context.Context, filter *repository.JobFilter) ([]*domain.Job, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// mockAuditService is a test double for AuditService.
|
||||
type mockAuditService struct {
|
||||
events []interface{}
|
||||
}
|
||||
|
||||
func (m *mockAuditService) RecordEvent(ctx context.Context, actor string, actorType domain.ActorType, event string, resourceType string, resourceID string, details map[string]interface{}) {
|
||||
m.events = append(m.events, map[string]interface{}{
|
||||
"actor": actor,
|
||||
"actor_type": actorType,
|
||||
"event": event,
|
||||
"resource_type": resourceType,
|
||||
"resource_id": resourceID,
|
||||
"details": details,
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerificationService_RecordVerificationResult_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockJobRepo := &mockJobRepository{
|
||||
jobs: map[string]*domain.Job{
|
||||
"j-test1": {
|
||||
ID: "j-test1",
|
||||
Status: domain.JobStatusCompleted,
|
||||
},
|
||||
},
|
||||
}
|
||||
mockAudit := &mockAuditService{events: []interface{}{}}
|
||||
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
|
||||
|
||||
result := &domain.VerificationResult{
|
||||
JobID: "j-test1",
|
||||
TargetID: "t-nginx1",
|
||||
ExpectedFingerprint: "abc123",
|
||||
ActualFingerprint: "abc123",
|
||||
Verified: true,
|
||||
VerifiedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err := service.RecordVerificationResult(ctx, result)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Check job was updated
|
||||
job, _ := mockJobRepo.Get(ctx, "j-test1")
|
||||
if job.VerificationStatus != domain.VerificationSuccess {
|
||||
t.Errorf("expected VerificationSuccess, got %s", job.VerificationStatus)
|
||||
}
|
||||
if !*job.VerifiedAt == result.VerifiedAt {
|
||||
t.Errorf("verified_at mismatch")
|
||||
}
|
||||
|
||||
// Check audit event was recorded
|
||||
if len(mockAudit.events) != 1 {
|
||||
t.Errorf("expected 1 audit event, got %d", len(mockAudit.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationService_RecordVerificationResult_Failed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockJobRepo := &mockJobRepository{
|
||||
jobs: map[string]*domain.Job{
|
||||
"j-test2": {
|
||||
ID: "j-test2",
|
||||
Status: domain.JobStatusCompleted,
|
||||
},
|
||||
},
|
||||
}
|
||||
mockAudit := &mockAuditService{events: []interface{}{}}
|
||||
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
|
||||
|
||||
result := &domain.VerificationResult{
|
||||
JobID: "j-test2",
|
||||
TargetID: "t-apache1",
|
||||
ExpectedFingerprint: "aaa111",
|
||||
ActualFingerprint: "bbb222",
|
||||
Verified: false,
|
||||
VerifiedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err := service.RecordVerificationResult(ctx, result)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
job, _ := mockJobRepo.Get(ctx, "j-test2")
|
||||
if job.VerificationStatus != domain.VerificationFailed {
|
||||
t.Errorf("expected VerificationFailed, got %s", job.VerificationStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationService_RecordVerificationResult_WithError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockJobRepo := &mockJobRepository{
|
||||
jobs: map[string]*domain.Job{
|
||||
"j-test3": {
|
||||
ID: "j-test3",
|
||||
Status: domain.JobStatusCompleted,
|
||||
},
|
||||
},
|
||||
}
|
||||
mockAudit := &mockAuditService{events: []interface{}{}}
|
||||
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
|
||||
|
||||
result := &domain.VerificationResult{
|
||||
JobID: "j-test3",
|
||||
TargetID: "t-haproxy1",
|
||||
VerifiedAt: time.Now().UTC(),
|
||||
Error: "connection refused",
|
||||
}
|
||||
|
||||
err := service.RecordVerificationResult(ctx, result)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
job, _ := mockJobRepo.Get(ctx, "j-test3")
|
||||
if job.VerificationStatus != domain.VerificationFailed {
|
||||
t.Errorf("expected VerificationFailed, got %s", job.VerificationStatus)
|
||||
}
|
||||
if job.VerificationError == nil || *job.VerificationError != "connection refused" {
|
||||
t.Error("expected verification error to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationService_RecordVerificationResult_JobNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockJobRepo := &mockJobRepository{
|
||||
jobs: map[string]*domain.Job{},
|
||||
}
|
||||
mockAudit := &mockAuditService{events: []interface{}{}}
|
||||
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
|
||||
|
||||
result := &domain.VerificationResult{
|
||||
JobID: "j-nonexistent",
|
||||
TargetID: "t-nginx1",
|
||||
VerifiedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err := service.RecordVerificationResult(ctx, result)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationService_RecordVerificationResult_MissingJobID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockJobRepo := &mockJobRepository{jobs: map[string]*domain.Job{}}
|
||||
mockAudit := &mockAuditService{events: []interface{}{}}
|
||||
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
|
||||
|
||||
result := &domain.VerificationResult{
|
||||
TargetID: "t-nginx1",
|
||||
VerifiedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err := service.RecordVerificationResult(ctx, result)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing job ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationService_RecordVerificationResult_NilResult(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockJobRepo := &mockJobRepository{jobs: map[string]*domain.Job{}}
|
||||
mockAudit := &mockAuditService{events: []interface{}{}}
|
||||
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
|
||||
|
||||
err := service.RecordVerificationResult(ctx, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationService_GetVerificationResult_Success(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
targetID := "t-nginx1"
|
||||
fp := "abc123"
|
||||
mockJobRepo := &mockJobRepository{
|
||||
jobs: map[string]*domain.Job{
|
||||
"j-test1": {
|
||||
ID: "j-test1",
|
||||
TargetID: &targetID,
|
||||
VerificationStatus: domain.VerificationSuccess,
|
||||
VerifiedAt: &now,
|
||||
VerificationFp: &fp,
|
||||
},
|
||||
},
|
||||
}
|
||||
mockAudit := &mockAuditService{events: []interface{}{}}
|
||||
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
|
||||
|
||||
result, err := service.GetVerificationResult(ctx, "j-test1")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.JobID != "j-test1" {
|
||||
t.Errorf("expected job ID j-test1, got %s", result.JobID)
|
||||
}
|
||||
if !result.Verified {
|
||||
t.Error("expected Verified to be true")
|
||||
}
|
||||
if result.ActualFingerprint != "abc123" {
|
||||
t.Errorf("expected fingerprint abc123, got %s", result.ActualFingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationService_GetVerificationResult_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockJobRepo := &mockJobRepository{
|
||||
jobs: map[string]*domain.Job{},
|
||||
}
|
||||
mockAudit := &mockAuditService{events: []interface{}{}}
|
||||
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
|
||||
|
||||
_, err := service.GetVerificationResult(ctx, "j-nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationService_GetVerificationResult_EmptyJobID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockJobRepo := &mockJobRepository{jobs: map[string]*domain.Job{}}
|
||||
mockAudit := &mockAuditService{events: []interface{}{}}
|
||||
service := NewVerificationService(mockJobRepo, mockAudit, slog.Default())
|
||||
|
||||
_, err := service.GetVerificationResult(ctx, "")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty job ID")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user