Compare commits

..

9 Commits

Author SHA1 Message Date
shankar0123 397d2a1588 fix(helm): remove fail on empty postgresql password for lint/template
Default to "changeme" so helm lint and helm template pass with stock
values. Operators override at install time via --set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:30:13 -04:00
shankar0123 65567d0d83 fix(helm): type comparison error and lint-time fail on empty apiKey
- Use gt (int .Values.server.replicas) 1 to avoid incompatible type
  comparison between YAML integer and template literal
- Remove fail directive for empty apiKey — lint runs with defaults,
  operators set the key via --set at install time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:28:05 -04:00
shankar0123 0abd984285 fix: staticcheck S1016 struct conversion + Helm with/else-if parse error
- Use type conversion DigestStatusCount(c) instead of struct literal
- Replace with...else-if (invalid in Go templates) with if...else-if chain
- Add *.bak and cmd/agent/*.key/*.pem to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:25:25 -04:00
shankar0123 ec21c9bb29 feat(m28+m29+m30): ACME ARI, email digest, and Helm chart
M28: ACME Renewal Information (RFC 9702) — CA-directed renewal timing
with cert ID computation, directory endpoint discovery, graceful
degradation for non-ARI CAs. 19 tests.

M29: Email notifier wiring + scheduled certificate digest — SMTP
connector bridged to service layer via NotifierAdapter, DigestService
with HTML email template, 7th scheduler loop (24h), digest preview/send
API endpoints and GUI card. 21 tests.

M30: Production-ready Helm chart — server Deployment, PostgreSQL
StatefulSet, agent DaemonSet, ConfigMaps, Secrets, Ingress, security
contexts, health probes, example values for dev/prod/ACME scenarios.

Also: OpenAPI spec updates, MCP tool additions, CI helm-lint job,
documentation updates across 5 doc files and README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:18:35 -04:00
shankar0123 cb2ef9d0e7 chore: remove obsolete testing.md and test-gap-prompt.md
These files are superseded by the comprehensive 34-section
docs/testing-guide.md. Removing to avoid confusion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 20:37:20 -04:00
shankar0123 da79dde611 revert: remove Docker Hub integration from release workflow and README
Restores release workflow to ghcr.io-only publishing.
Removes Docker Pulls badge from README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 19:34:29 -04:00
shankar0123 935ea1bf9f ci: add Docker Hub dual-push and pulls badge to README
Release workflow now pushes to both ghcr.io and Docker Hub on tag.
Adds shields.io Docker Pulls badge to README for social proof.
Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repo secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 19:24:12 -04:00
shankar0123 11e752ac01 docs: add v2.1.0 release gate note to README and testing guide
v2.1.0 will be tagged after all 34 manual QA sections pass.
Updates sign-off table version reference from v2.0.7 to v2.1.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 18:09:41 -04:00
shankar0123 03472072b8 test + docs: close 12 test gaps (~250 new tests) and expand testing guide to 34 parts
Implements all P0-P2 test gaps from docs/test-gap-prompt.md:
- Deployment service tests (20), target service tests (18), scheduler tests (8)
- Agent binary tests (48), CSR renewal tests (8), short-lived cert tests (7)
- Domain model tests (25), context cancellation tests (9), concurrency tests (7)
- Handler negative-path tests (23 across 5 files)
- Frontend error handling tests (86) and API client tests (7)

Expands testing-guide.md from 28 to 34 parts covering certificate export,
S/MIME/EKU, OCSP/DER CRL, body size limits, Apache/HAProxy connectors,
and sub-CA mode. Fixes stale profile count (4->5) and updates sign-off table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 17:57:25 -04:00
86 changed files with 12562 additions and 47 deletions
+17
View File
@@ -125,3 +125,20 @@ jobs:
- name: Build Frontend
working-directory: web
run: npx vite build
helm-lint:
name: Helm Chart Validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: '3.13.0'
- name: Lint Helm Chart
run: helm lint deploy/helm/certctl/
- name: Template Helm Chart
run: helm template certctl deploy/helm/certctl/ > /dev/null
+5
View File
@@ -43,6 +43,11 @@ vendor/
tmp/
temp/
*.log
*.bak
# Private keys (agent-generated, never commit)
cmd/agent/*.key
cmd/agent/*.pem
# Database
*.db
+33 -13
View File
@@ -39,6 +39,8 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
> **Next release:** v2.1.0 will be tagged after the full V2 feature suite passes manual QA across all 34 sections of the [testing guide](docs/testing-guide.md). Automated CI (1,471 Go tests + 193 frontend tests) gates every commit; the manual playbook covers integration, deployment, and UX verification that unit tests can't reach.
## Why certctl Exists
Certificate lifecycle tooling today falls into two camps: expensive enterprise platforms (Venafi, Keyfactor, Sectigo) that cost six figures and take months to deploy, or single-purpose tools (cert-manager, certbot) that handle one slice of the problem. If you run a mixed infrastructure — some NGINX, some Apache, a few HAProxy nodes, maybe an F5 — and you need to manage certificates from multiple CAs, there's nothing self-hosted that covers the full lifecycle without vendor lock-in.
@@ -53,13 +55,19 @@ For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venaf
certctl gives you a single pane of glass for every TLS certificate in your organization:
- **Web dashboard** — full certificate inventory with status, ownership, expiration heatmaps, and bulk operations
- **REST API** — 95 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation
- **Agents** — generate private keys locally, discover existing certs on disk, submit CSRs (private keys never leave your servers)
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents
- **Web dashboard** — 22 operational pages: certificate inventory, deployment timeline with TLS verification, bulk operations (renew/revoke/reassign), discovery triage, network scan management, approval workflows, audit trail with CSV/JSON export, agent fleet overview with OS/arch grouping, short-lived credential monitoring, digest email preview
- **REST API** — 99 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation, with sparse fields, sort, cursor pagination, and time-range filters
- **Agents** — generate private keys locally (ECDSA P-256), discover existing certs on disk (PEM/DER), submit CSRs only (private keys never leave your servers)
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents, concurrent scanning with configurable timeouts
- **Certificate export** — PEM (JSON or file download) and PKCS#12 formats, with audit trail; private keys never included
- **S/MIME + EKU support** — issue certificates with emailProtection, codeSigning, timeStamping, clientAuth EKUs; email SAN routing for S/MIME
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol
- **Post-deployment verification** — agent-side TLS probe confirms the target serves the correct certificate by SHA-256 fingerprint match
- **Approval workflows** — require human sign-off on renewals before deployment
- **Background scheduler** — watches expiration dates and triggers renewals automatically, handling constant rotation at 47-day lifespans without human involvement
- **Background scheduler** — 7 automated loops: renewal checks, job processing, agent health, notifications, short-lived cert expiry, network scanning, and scheduled certificate digest emails
- **ACME Renewal Information (ARI, RFC 9702)** — CA-directed renewal timing; certctl asks the CA when to renew instead of using fixed thresholds
- **Scheduled certificate digest emails** — HTML digest with certificate stats, expiration timeline, and job health; optional daily briefing via SMTP
- **Helm chart** — Production-ready Kubernetes deployment with server, PostgreSQL, and agent DaemonSet
For the full capability breakdown — revocation infrastructure, policy engine, observability, EST enrollment, and more — see the [Feature Inventory](docs/features.md).
@@ -131,6 +139,8 @@ All connectors are pluggable — build your own by implementing the [connector i
</tr>
</table>
> **22 operational GUI pages** covering the full certificate lifecycle: dashboard, certificates (list + detail with EKU badges, deployment timeline, TLS verification status), agents, fleet overview, jobs (with approval workflow), notifications, policies, profiles, issuers, targets (wizard with NGINX/Apache/HAProxy/Traefik/Caddy/F5/IIS), owners, teams, agent groups, audit trail, short-lived credentials, discovery triage, and network scan management.
## Quick Start
### Docker Pull
@@ -350,7 +360,7 @@ make docker-clean # Stop + remove volumes
## API Overview
95 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
99 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
### Key Endpoints
```
@@ -385,6 +395,10 @@ GET /api/v1/jobs/{id}/verification Get verification status
GET /api/v1/metrics/prometheus Prometheus exposition format
GET /api/v1/stats/summary Dashboard summary
# Digest emails (scheduled briefing)
GET /api/v1/digest/preview HTML email preview
POST /api/v1/digest/send Send digest immediately
# EST enrollment (RFC 7030)
POST /.well-known/est/simpleenroll Device certificate enrollment
GET /.well-known/est/cacerts CA certificate chain (PKCS#7)
@@ -459,11 +473,11 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
### V2: Operational Maturity
21 milestones complete, 1100+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
30 milestones complete, 1500+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
**What shipped (all ✅):**
- **Issuers** — Sub-CA mode (enterprise root chains), ACME DNS-01 + DNS-PERSIST-01 (wildcard certs, any DNS provider), step-ca (native /sign API), OpenSSL/Custom CA (script-based signing)
- **Issuers** — Sub-CA mode (enterprise root chains), ACME DNS-01 + DNS-PERSIST-01 (wildcard certs, any DNS provider), step-ca (native /sign API), OpenSSL/Custom CA (script-based signing), ACME ARI (RFC 9702, CA-directed renewal timing)
- **Revocation** — RFC 5280 reason codes, DER-encoded X.509 CRL, embedded OCSP responder, short-lived cert exemption
- **Profiles + Ownership** — certificate profiles (key types, max TTL, crypto constraints), ownership tracking (owners + teams), dynamic agent groups, interactive renewal approval
- **GUI Operations** — bulk renew/revoke/reassign, deployment timeline, inline policy editor, target wizard, audit export (CSV/JSON), short-lived credentials view
@@ -472,14 +486,20 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
- **EST Server** (RFC 7030) — device/WiFi certificate enrollment, PKCS#7 wire format, configurable issuer + profile binding
- **MCP Server** — 78 API operations as AI tools for Claude, Cursor, and any MCP-compatible client
- **CLI** — 12 subcommands (list/get/renew/revoke certs, agents, jobs, import, status), JSON/table output
- **Notifications** — Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
- **Notifications** — Email (SMTP), Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
- **API Enhancements** — sparse fields, sort, time-range filters, cursor pagination, immutable API audit logging
- **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides
- **Post-Deployment TLS Verification** — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match
- **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based)
- **Certificate Export** — PEM (JSON or file download) and PKCS#12 formats, private keys never included (agent-side only), audit trail
- **S/MIME Support** — EKU-aware issuance (emailProtection, codeSigning, timeStamping), adaptive KeyUsage flags, email SAN routing
- **Post-Deployment TLS Verification** — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match, verification status visible in deployment timeline
- **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based), both in target wizard GUI
- **Certificate Export** — PEM (JSON or file download) and PKCS#12 formats, private keys never included (agent-side only), audit trail, GUI export buttons
- **S/MIME Support** — EKU-aware issuance (emailProtection, codeSigning, timeStamping), adaptive KeyUsage flags, email SAN routing, EKU badges in GUI
- **ACME ARI (RFC 9702)** — CA-directed renewal timing with graceful threshold fallback for non-ARI CAs, reduces unnecessary early renewals
- **Scheduled Certificate Digest** — HTML email digests with certificate stats, expiration timeline, job trends, and agent health; optional daily/hourly/weekly briefings via SMTP
- **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet, Agent DaemonSet, security contexts, resource limits, optional Ingress, ServiceAccount
- **ACME ARI (RFC 9702)** — CA-directed renewal timing: instead of renewing at fixed thresholds, the CA tells certctl the optimal renewal window, gracefully degrading to thresholds when ARI is unavailable
- **Email Digest Service** — Scheduled HTML digest emails with certificate stats, expiration timeline (90d), job health, and active agent count; falls back to certificate owner emails if no recipients configured
- **Helm Chart** — Production-ready Kubernetes deployment with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, optional Ingress, security contexts, and full values.yaml configuration
### V3: certctl Pro
+52
View File
@@ -62,6 +62,8 @@ tags:
description: Certificate discovery — filesystem scanning by agents and network TLS probing
- name: Network Scan
description: Network scan target management for active TLS certificate discovery
- name: Digest
description: Scheduled certificate digest email notifications
paths:
# ─── Health & Auth ───────────────────────────────────────────────────
@@ -2372,6 +2374,56 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
# ─── Digest ────────────────────────────────────────────────────────
/api/v1/digest/preview:
get:
tags: [Digest]
summary: Preview digest email
description: |
Returns an HTML preview of the scheduled certificate digest email.
This includes a summary of certificate status, pending jobs, and expiring certificates.
operationId: previewDigest
responses:
"200":
description: HTML digest email preview
content:
text/html:
schema:
type: string
example: "<html>...</html>"
"503":
description: Digest service not configured
content:
application/json:
schema:
$ref: "#/components/schemas/StatusMessageResponse"
"500":
$ref: "#/components/responses/InternalError"
/api/v1/digest/send:
post:
tags: [Digest]
summary: Send digest email
description: |
Triggers immediate sending of the certificate digest email to configured recipients.
If no explicit recipients are configured, sends to certificate owners.
operationId: sendDigest
responses:
"200":
description: Digest sent successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusMessageResponse"
"503":
description: Digest service not configured
content:
application/json:
schema:
$ref: "#/components/schemas/StatusMessageResponse"
"500":
$ref: "#/components/responses/InternalError"
# ═══════════════════════════════════════════════════════════════════════
components:
securitySchemes:
+830
View File
@@ -0,0 +1,830 @@
package main
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"io"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// TestAgent_Heartbeat_Success tests that heartbeat sends correct metadata and handles 200 response.
func TestAgent_Heartbeat_Success(t *testing.T) {
// Create mock server to validate heartbeat request
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify correct endpoint and method
if r.URL.Path != "/api/v1/agents/a-test-agent/heartbeat" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Errorf("unexpected method: %s, expected POST", r.Method)
}
// Verify auth header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-key" {
t.Errorf("unexpected auth header: %s", auth)
}
// Verify request body contains required fields
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode payload: %v", err)
}
// Check required fields
if _, ok := payload["version"]; !ok {
t.Error("missing version in heartbeat")
}
if _, ok := payload["hostname"]; !ok {
t.Error("missing hostname in heartbeat")
}
if _, ok := payload["os"]; !ok {
t.Error("missing os in heartbeat")
}
if _, ok := payload["architecture"]; !ok {
t.Error("missing architecture in heartbeat")
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := &AgentConfig{
ServerURL: server.URL,
APIKey: "test-key",
AgentID: "a-test-agent",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
// Should not panic
agent.sendHeartbeat(context.Background())
}
// TestAgent_Heartbeat_ServerError tests that heartbeat handles 500 response gracefully.
func TestAgent_Heartbeat_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
cfg := &AgentConfig{
ServerURL: server.URL,
APIKey: "test-key",
AgentID: "a-test-agent",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
// Should increment consecutive failures
failureBefore := agent.consecutiveFailures
agent.sendHeartbeat(context.Background())
failureAfter := agent.consecutiveFailures
if failureAfter != failureBefore+1 {
t.Errorf("expected consecutive failures to increment, got %d, want %d", failureAfter, failureBefore+1)
}
}
// TestAgent_Heartbeat_ConnectionError tests that heartbeat handles connection error.
func TestAgent_Heartbeat_ConnectionError(t *testing.T) {
// Use an invalid address that will fail immediately
cfg := &AgentConfig{
ServerURL: "http://invalid-host-that-does-not-exist.local:9999",
APIKey: "test-key",
AgentID: "a-test-agent",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
// Should fail due to connection error
agent.sendHeartbeat(context.Background())
if agent.consecutiveFailures != 1 {
t.Errorf("expected consecutive failures to be 1, got %d", agent.consecutiveFailures)
}
}
// TestAgent_PollWork_NoWork tests that work polling handles empty work list.
func TestAgent_PollWork_NoWork(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/agents/a-test-agent/work" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodGet {
t.Errorf("unexpected method: %s", r.Method)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(WorkResponse{
Jobs: []JobItem{},
Count: 0,
})
}))
defer server.Close()
cfg := &AgentConfig{
ServerURL: server.URL,
APIKey: "test-key",
AgentID: "a-test-agent",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
// Should not panic
agent.pollForWork(context.Background())
}
// TestAgent_PollWork_Success tests that work polling parses and returns jobs correctly.
func TestAgent_PollWork_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
workResp := WorkResponse{
Count: 2,
Jobs: []JobItem{
{
ID: "j-csr-001",
Type: "Issuance",
CertificateID: "mc-001",
CommonName: "example.com",
SANs: []string{"www.example.com"},
Status: "AwaitingCSR",
},
{
ID: "j-deploy-001",
Type: "Deployment",
CertificateID: "mc-001",
TargetID: strPtr("t-nginx-1"),
TargetType: "NGINX",
TargetConfig: json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`),
Status: "Pending",
},
},
}
json.NewEncoder(w).Encode(workResp)
}))
defer server.Close()
cfg := &AgentConfig{
ServerURL: server.URL,
APIKey: "test-key",
AgentID: "a-test-agent",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
// Should not panic; work items are processed in separate gorines in real usage
agent.pollForWork(context.Background())
}
// TestSplitPEMChain tests PEM chain splitting into cert and chain.
func TestSplitPEMChain(t *testing.T) {
// Create two test certificates
cert1, _ := generateTestCertWithCN("cert1.example.com")
cert2, _ := generateTestCertWithCN("cert2.example.com")
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
cert1PEM := string(pem.EncodeToMemory(block1))
cert2PEM := string(pem.EncodeToMemory(block2))
chainPEM := cert1PEM + "\n" + cert2PEM
// Split
certOnly, chain := splitPEMChain(chainPEM)
// Verify cert part
if !bytes.Contains([]byte(certOnly), []byte("-----BEGIN CERTIFICATE-----")) {
t.Error("cert part missing BEGIN marker")
}
// Verify chain part
if !bytes.Contains([]byte(chain), []byte("-----BEGIN CERTIFICATE-----")) {
t.Error("chain part missing BEGIN marker")
}
// Verify they're different
if certOnly == chain {
t.Error("cert and chain should be different")
}
}
// TestSplitPEMChain_SingleCert tests PEM chain splitting with single certificate.
func TestSplitPEMChain_SingleCert(t *testing.T) {
cert, _ := generateTestCertWithCN("example.com")
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
certPEM := string(pem.EncodeToMemory(block))
certOnly, chain := splitPEMChain(certPEM)
if certOnly != certPEM {
t.Error("single cert should be returned as-is")
}
if chain != "" {
t.Error("chain should be empty for single cert")
}
}
// TestSplitPEMChain_InvalidPEM tests PEM chain splitting with invalid PEM.
func TestSplitPEMChain_InvalidPEM(t *testing.T) {
invalidPEM := "not a valid pem"
certOnly, chain := splitPEMChain(invalidPEM)
if certOnly != invalidPEM {
t.Error("invalid PEM should be returned as-is in cert part")
}
if chain != "" {
t.Error("chain should be empty for invalid PEM")
}
}
// TestParsePEMFile tests parsing a PEM file with certificates.
func TestParsePEMFile(t *testing.T) {
// Create a temporary file with a PEM certificate
tmpdir := t.TempDir()
certPath := filepath.Join(tmpdir, "cert.pem")
cert, _ := generateTestCert()
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
certPEM := pem.EncodeToMemory(block)
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
t.Fatalf("failed to write test cert: %v", err)
}
cfg := &AgentConfig{
ServerURL: "http://localhost:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
// Parse the file
entries := agent.parsePEMFile(certPath)
if len(entries) != 1 {
t.Errorf("expected 1 certificate, got %d", len(entries))
return
}
entry := entries[0]
if entry.CommonName != "test.example.com" {
t.Errorf("expected CN 'test.example.com', got '%s'", entry.CommonName)
}
if entry.SourceFormat != "PEM" {
t.Errorf("expected format 'PEM', got '%s'", entry.SourceFormat)
}
if entry.SourcePath != certPath {
t.Errorf("expected path '%s', got '%s'", certPath, entry.SourcePath)
}
// Verify fingerprint is non-empty and correct length (SHA256 hex = 64 chars)
if len(entry.FingerprintSHA256) != 64 {
t.Errorf("expected 64-char fingerprint, got %d", len(entry.FingerprintSHA256))
}
}
// TestParsePEMFile_MultipleCerts tests parsing a PEM file with multiple certificates.
func TestParsePEMFile_MultipleCerts(t *testing.T) {
tmpdir := t.TempDir()
certPath := filepath.Join(tmpdir, "chain.pem")
cert1, _ := generateTestCertWithCN("cert1.example.com")
cert2, _ := generateTestCertWithCN("cert2.example.com")
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
certPEM := append(pem.EncodeToMemory(block1), pem.EncodeToMemory(block2)...)
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
t.Fatalf("failed to write test cert: %v", err)
}
cfg := &AgentConfig{
ServerURL: "http://localhost:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
entries := agent.parsePEMFile(certPath)
if len(entries) != 2 {
t.Errorf("expected 2 certificates, got %d", len(entries))
}
}
// TestParseDERFile tests parsing a DER-encoded certificate file.
func TestParseDERFile(t *testing.T) {
tmpdir := t.TempDir()
derPath := filepath.Join(tmpdir, "cert.der")
cert, _ := generateTestCertWithCN("test.example.com")
if err := os.WriteFile(derPath, cert.Raw, 0644); err != nil {
t.Fatalf("failed to write test cert: %v", err)
}
cfg := &AgentConfig{
ServerURL: "http://localhost:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
entry, err := agent.parseDERFile(derPath)
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if entry.CommonName != "test.example.com" {
t.Errorf("expected CN 'test.example.com', got '%s'", entry.CommonName)
}
if entry.SourceFormat != "DER" {
t.Errorf("expected format 'DER', got '%s'", entry.SourceFormat)
}
if len(entry.FingerprintSHA256) != 64 {
t.Errorf("expected 64-char fingerprint, got %d", len(entry.FingerprintSHA256))
}
}
// TestParseDERFile_Invalid tests parsing an invalid DER file.
func TestParseDERFile_Invalid(t *testing.T) {
tmpdir := t.TempDir()
derPath := filepath.Join(tmpdir, "invalid.der")
if err := os.WriteFile(derPath, []byte("not a valid der file"), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := &AgentConfig{
ServerURL: "http://localhost:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
_, err := agent.parseDERFile(derPath)
if err == nil {
t.Error("expected error for invalid DER file")
}
}
// TestScanDirectory tests scanning a directory for certificate files.
func TestScanDirectory(t *testing.T) {
tmpdir := t.TempDir()
// Create subdirectory
subdir := filepath.Join(tmpdir, "subdir")
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
// Create certificates with various extensions
cert1, _ := generateTestCertWithCN("cert1.example.com")
cert2, _ := generateTestCertWithCN("cert2.example.com")
// Write cert1.pem
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
if err := os.WriteFile(filepath.Join(tmpdir, "cert1.pem"), pem.EncodeToMemory(block1), 0644); err != nil {
t.Fatalf("failed to write cert1: %v", err)
}
// Write cert2.crt in subdir
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
if err := os.WriteFile(filepath.Join(subdir, "cert2.crt"), pem.EncodeToMemory(block2), 0644); err != nil {
t.Fatalf("failed to write cert2: %v", err)
}
cfg := &AgentConfig{
ServerURL: "http://localhost:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
DiscoveryDirs: []string{tmpdir},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
// Simulate directory walk manually (as runDiscoveryScan does)
var certs []discoveredCertEntry
filepath.Walk(tmpdir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
ext := filepath.Ext(path)
switch ext {
case ".pem", ".crt":
found := agent.parsePEMFile(path)
certs = append(certs, found...)
}
return nil
})
if len(certs) != 2 {
t.Errorf("expected 2 certificates from directory scan, got %d", len(certs))
}
}
// TestCreateTargetConnector_NGINX tests connector creation for NGINX target.
func TestCreateTargetConnector_NGINX(t *testing.T) {
cfg := &AgentConfig{
ServerURL: "http://localhost:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
configJSON := json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`)
connector, err := agent.createTargetConnector("NGINX", configJSON)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if connector == nil {
t.Error("expected connector to be non-nil")
}
}
// TestCreateTargetConnector_Unsupported tests connector creation for unsupported type.
func TestCreateTargetConnector_Unsupported(t *testing.T) {
cfg := &AgentConfig{
ServerURL: "http://localhost:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
_, err := agent.createTargetConnector("UnsupportedType", nil)
if err == nil {
t.Error("expected error for unsupported target type")
}
}
// TestFetchCertificate_Success tests fetching a certificate from the control plane.
func TestFetchCertificate_Success(t *testing.T) {
cert, _ := generateTestCertWithCN("test.example.com")
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
expectedCertPEM := string(pem.EncodeToMemory(block))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/agents/a-test/certificates/mc-001" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"certificate_pem": expectedCertPEM,
})
}))
defer server.Close()
cfg := &AgentConfig{
ServerURL: server.URL,
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
certPEM, err := agent.fetchCertificate(context.Background(), "mc-001")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if certPEM != expectedCertPEM {
t.Error("certificate PEM mismatch")
}
}
// TestFetchCertificate_NotFound tests fetching a non-existent certificate.
func TestFetchCertificate_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer server.Close()
cfg := &AgentConfig{
ServerURL: server.URL,
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
_, err := agent.fetchCertificate(context.Background(), "mc-nonexistent")
if err == nil {
t.Error("expected error for non-existent certificate")
}
}
// TestReportJobStatus_Success tests reporting job status to the control plane.
func TestReportJobStatus_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/agents/a-test/jobs/j-001/status" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Errorf("unexpected method: %s", r.Method)
}
var payload map[string]string
json.NewDecoder(r.Body).Decode(&payload)
if payload["status"] != "Completed" {
t.Errorf("expected status 'Completed', got '%s'", payload["status"])
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := &AgentConfig{
ServerURL: server.URL,
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
err := agent.reportJobStatus(context.Background(), "j-001", "Completed", "")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
// TestReportJobStatus_WithError tests reporting job status with error message.
func TestReportJobStatus_WithError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]string
json.NewDecoder(r.Body).Decode(&payload)
if payload["status"] != "Failed" {
t.Errorf("expected status 'Failed', got '%s'", payload["status"])
}
if payload["error"] != "deployment failed" {
t.Errorf("expected error 'deployment failed', got '%s'", payload["error"])
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := &AgentConfig{
ServerURL: server.URL,
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
err := agent.reportJobStatus(context.Background(), "j-001", "Failed", "deployment failed")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
// TestMakeRequest_Success tests making an authenticated HTTP request.
func TestMakeRequest_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify auth header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-key" {
t.Errorf("unexpected auth: %s", auth)
}
// Verify content-type
ct := r.Header.Get("Content-Type")
if ct != "application/json" {
t.Errorf("unexpected content-type: %s", ct)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := &AgentConfig{
ServerURL: server.URL,
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
resp, err := agent.makeRequest(context.Background(), http.MethodPost, "/test", map[string]string{"key": "value"})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("unexpected status: %d", resp.StatusCode)
}
}
// TestMakeRequest_InvalidURL tests making a request with invalid URL.
func TestMakeRequest_InvalidURL(t *testing.T) {
cfg := &AgentConfig{
ServerURL: "http://invalid-host-that-does-not-exist.local:9999",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
_, err := agent.makeRequest(context.Background(), http.MethodGet, "/test", nil)
if err == nil {
t.Error("expected error for unreachable host")
}
}
// TestCertKeyInfo tests extraction of key algorithm and size from certificates.
func TestCertKeyInfo(t *testing.T) {
tests := []struct {
name string
genKey func() interface{}
expectedAlg string
minBitSize int
}{
{
name: "ECDSA P-256",
genKey: func() interface{} {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
return key.Public()
},
expectedAlg: "ECDSA",
minBitSize: 256,
},
{
name: "RSA 2048",
genKey: func() interface{} {
key, _ := rsa.GenerateKey(rand.Reader, 2048)
return key.Public()
},
expectedAlg: "RSA",
minBitSize: 2048,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pubKey := tt.genKey()
// Create certificate with this key
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "test.com",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
var privKey interface{}
if ecdsaPub, ok := pubKey.(*ecdsa.PublicKey); ok {
key, _ := ecdsa.GenerateKey(ecdsaPub.Curve, rand.Reader)
privKey = key
} else if rsaPub, ok := pubKey.(*rsa.PublicKey); ok {
key, _ := rsa.GenerateKey(rand.Reader, rsaPub.N.BitLen())
privKey = key
}
certDER, _ := x509.CreateCertificate(rand.Reader, template, template, pubKey, privKey)
cert, _ := x509.ParseCertificate(certDER)
alg, bitSize := certKeyInfo(cert)
if alg != tt.expectedAlg {
t.Errorf("expected algorithm %s, got %s", tt.expectedAlg, alg)
}
if bitSize < tt.minBitSize {
t.Errorf("expected bitsize >= %d, got %d", tt.minBitSize, bitSize)
}
})
}
}
// TestNewAgent tests agent initialization.
func TestNewAgent(t *testing.T) {
cfg := &AgentConfig{
ServerURL: "http://localhost:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
if agent.config != cfg {
t.Error("config not set correctly")
}
if agent.heartbeatInterval != 60*time.Second {
t.Errorf("expected heartbeat interval 60s, got %v", agent.heartbeatInterval)
}
if agent.pollInterval != 30*time.Second {
t.Errorf("expected poll interval 30s, got %v", agent.pollInterval)
}
if agent.client == nil {
t.Error("HTTP client not initialized")
}
}
// TestNewAgent_WithLogger tests agent initialization with logger.
func TestNewAgent_WithLogger(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := &AgentConfig{
ServerURL: "http://localhost:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
}
agent := NewAgent(cfg, logger)
if agent.logger != logger {
t.Error("logger not set correctly")
}
}
// Helper to create test certificates with specific CN
func generateTestCertWithCN(commonName string) (*x509.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: commonName,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
DNSNames: []string{commonName},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, err
}
return x509.ParseCertificate(certDER)
}
// Helper to create string pointer
func strPtr(s string) *string {
return &s
}
+46
View File
@@ -21,6 +21,7 @@ import (
"github.com/shankar0123/certctl/internal/connector/issuer/local"
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack"
@@ -189,6 +190,25 @@ func main() {
logger.Info("OpsGenie notifier enabled")
}
// Wire email notifier if SMTP is configured
var emailAdapter *notifyemail.NotifierAdapter
if cfg.Notifiers.SMTPHost != "" && cfg.Notifiers.SMTPFromAddress != "" {
emailConnector := notifyemail.New(&notifyemail.Config{
SMTPHost: cfg.Notifiers.SMTPHost,
SMTPPort: cfg.Notifiers.SMTPPort,
Username: cfg.Notifiers.SMTPUsername,
Password: cfg.Notifiers.SMTPPassword,
FromAddress: cfg.Notifiers.SMTPFromAddress,
UseTLS: cfg.Notifiers.SMTPUseTLS,
}, logger)
emailAdapter = notifyemail.NewNotifierAdapter(emailConnector)
notifierRegistry["Email"] = emailAdapter
logger.Info("Email notifier enabled",
"smtp_host", cfg.Notifiers.SMTPHost,
"smtp_port", cfg.Notifiers.SMTPPort,
"from", cfg.Notifiers.SMTPFromAddress)
}
notificationService := service.NewNotificationService(notificationRepo, notifierRegistry)
notificationService.SetOwnerRepo(ownerRepo)
@@ -265,6 +285,26 @@ func main() {
verificationHandler := handler.NewVerificationHandler(verificationService)
exportService := service.NewExportService(certificateRepo, auditService)
exportHandler := handler.NewExportHandler(exportService)
// Initialize digest service (requires email notifier)
var digestService *service.DigestService
var digestHandler *handler.DigestHandler
if cfg.Digest.Enabled && emailAdapter != nil {
digestService = service.NewDigestService(
statsService, certificateRepo, ownerRepo, emailAdapter, cfg.Digest.Recipients, logger,
)
digestHandler = handler.NewDigestHandler(digestService)
logger.Info("digest service enabled",
"interval", cfg.Digest.Interval.String(),
"recipients", len(cfg.Digest.Recipients))
} else {
// Create a no-op digest handler for route registration
digestHandler = handler.NewDigestHandler(nil)
if cfg.Digest.Enabled && emailAdapter == nil {
logger.Warn("digest enabled but SMTP not configured — digest emails will not be sent")
}
}
logger.Info("initialized all handlers")
// Create context with cancellation
@@ -290,6 +330,11 @@ func main() {
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String())
}
if digestService != nil {
sched.SetDigestService(digestService)
sched.SetDigestInterval(cfg.Digest.Interval)
logger.Info("digest scheduler enabled", "interval", cfg.Digest.Interval.String())
}
// Start scheduler
logger.Info("starting scheduler")
@@ -319,6 +364,7 @@ func main() {
NetworkScan: networkScanHandler,
Verification: verificationHandler,
Export: exportHandler,
Digest: *digestHandler,
})
// Register EST (RFC 7030) handlers if enabled
if cfg.EST.Enabled {
+1
View File
@@ -18,6 +18,7 @@ services:
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/011_seed_demo.sql
networks:
+461
View File
@@ -0,0 +1,461 @@
# Certctl Helm Chart - Complete Summary
## Overview
A production-ready Helm chart for deploying certctl (self-hosted certificate lifecycle management platform) on Kubernetes. The chart provides:
- High availability support with multi-replica deployments
- Persistent PostgreSQL database with automatic schema migration
- DaemonSet or Deployment-based agent deployment
- Comprehensive security contexts and RBAC
- Multiple deployment scenarios (dev, prod, HA, external DB)
- Full documentation and examples
## Chart Metadata
- **Name**: certctl
- **Chart Version**: 0.1.0
- **App Version**: 2.1.0
- **Type**: application
- **License**: BSL-1.1 (converts to Apache 2.0 in 2033)
## File Structure
```
deploy/helm/
├── README.md # Main Helm chart documentation
├── DEPLOYMENT_GUIDE.md # Step-by-step deployment guide
├── CHART_SUMMARY.md # This file
├── certctl/
│ ├── Chart.yaml # Chart metadata
│ ├── values.yaml # Default configuration values
│ ├── .helmignore # Files to ignore when building chart
│ │
│ └── templates/
│ ├── _helpers.tpl # Helm template helper functions
│ ├── NOTES.txt # Post-deployment notes
│ │
│ ├── server-deployment.yaml # Certctl API server deployment
│ ├── server-service.yaml # Server Kubernetes service
│ ├── server-configmap.yaml # Server configuration
│ ├── server-secret.yaml # Server secrets (API key, DB password, etc)
│ │
│ ├── postgres-statefulset.yaml # PostgreSQL database statefulset
│ ├── postgres-service.yaml # PostgreSQL headless service
│ ├── postgres-secret.yaml # Database credentials secret
│ │
│ ├── agent-daemonset.yaml # Certctl agent daemonset/deployment
│ ├── agent-configmap.yaml # Agent configuration
│ │
│ ├── ingress.yaml # Optional ingress resource
│ └── serviceaccount.yaml # ServiceAccount and RBAC
└── examples/
├── values-dev.yaml # Development/testing configuration
├── values-prod-ha.yaml # Production HA configuration
├── values-external-db.yaml # External PostgreSQL (RDS, Cloud SQL)
└── values-acme-dns01.yaml # ACME with DNS-01 (Let's Encrypt)
```
## Key Components
### 1. Server Deployment
**File**: `templates/server-deployment.yaml`
- Manages certctl API server instances
- Configurable replicas (default: 1)
- Health checks (liveness & readiness probes)
- Security context: non-root user, read-only filesystem
- Resource limits (default: 500m CPU, 512Mi memory)
- Automatic restart on failure
**Values**:
```yaml
server:
replicas: 1
port: 8443
auth:
type: api-key
apiKey: "REQUIRED"
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: 500m, memory: 512Mi}
```
### 2. PostgreSQL StatefulSet
**File**: `templates/postgres-statefulset.yaml`
- Persistent database storage
- Automatic schema migrations on startup
- Single replica (can be extended with external HA tools)
- Health checks via pg_isready
- Configurable storage size and class
- Security context: non-root user (UID 999)
**Values**:
```yaml
postgresql:
enabled: true
storage:
size: 10Gi
storageClass: "" # Use default
auth:
database: certctl
username: certctl
password: "REQUIRED"
```
### 3. Agent DaemonSet/Deployment
**File**: `templates/agent-daemonset.yaml`
- DaemonSet mode: one agent per Kubernetes node
- Deployment mode: custom number of agent replicas
- Local key storage with secure permissions (0600)
- Health checks and automatic restart
- Optional certificate discovery from filesystem
**Values**:
```yaml
agent:
enabled: true
kind: DaemonSet # or Deployment
replicas: 1 # for Deployment only
keyDir: /var/lib/certctl/keys
discoveryDirs: "/etc/ssl/certs" # optional
```
### 4. Ingress (Optional)
**File**: `templates/ingress.yaml`
- Optional HTTPS ingress
- cert-manager integration for automatic TLS
- Multiple host support
- Path-based routing
**Values**:
```yaml
ingress:
enabled: false
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: certctl.example.com
paths:
- path: /
pathType: Prefix
```
### 5. ConfigMaps and Secrets
**Files**:
- `server-configmap.yaml` - Non-secret server configuration
- `server-secret.yaml` - API key, database URL, SMTP password
- `postgres-secret.yaml` - Database credentials
- `agent-configmap.yaml` - Agent configuration
All secrets are base64-encoded and stored in Kubernetes Secrets.
### 6. ServiceAccount and RBAC
**File**: `templates/serviceaccount.yaml`
- Optional ServiceAccount creation
- Optional RBAC (ClusterRole, ClusterRoleBinding)
- Namespace-scoped by default
## Deployment Scenarios
### Development Setup
Use `examples/values-dev.yaml`:
```bash
helm install certctl certctl/ \
--values examples/values-dev.yaml \
--set server.auth.apiKey="dev-key" \
--set postgresql.auth.password="dev-password"
```
**Features**:
- Single server replica
- Demo auth (no API key required)
- Small database (5Gi)
- LoadBalancer service for easy access
- Debug logging level
### Production HA Setup
Use `examples/values-prod-ha.yaml`:
```bash
helm install certctl certctl/ \
--values examples/values-prod-ha.yaml \
--set server.auth.apiKey="$(openssl rand -base64 32)" \
--set postgresql.auth.password="$(openssl rand -base64 32)"
```
**Features**:
- 3 server replicas with pod anti-affinity
- Large database storage (100Gi)
- Pod disruption budgets
- Prometheus monitoring enabled
- Production resource limits
### External PostgreSQL
Use `examples/values-external-db.yaml`:
```bash
helm install certctl certctl/ \
--values examples/values-external-db.yaml \
--set postgresql.enabled=false \
--set 'server.env.CERTCTL_DATABASE_URL=postgres://...'
```
**Use cases**:
- AWS RDS
- Google Cloud SQL
- Azure Database for PostgreSQL
- External self-managed PostgreSQL
### ACME with DNS-01
Use `examples/values-acme-dns01.yaml`:
```bash
helm install certctl certctl/ \
--values examples/values-acme-dns01.yaml
```
**Enables**:
- Automatic certificate issuance from Let's Encrypt
- DNS-01 challenge (wildcard support)
- Custom DNS provider scripts
## Configuration Options
### Server Configuration
| Option | Default | Description |
|--------|---------|-------------|
| `server.replicas` | 1 | Number of server replicas |
| `server.port` | 8443 | Server port |
| `server.auth.type` | api-key | Authentication type |
| `server.auth.apiKey` | "" | API key (REQUIRED) |
| `server.logging.level` | info | Log level |
| `server.logging.format` | json | Log format |
### PostgreSQL Configuration
| Option | Default | Description |
|--------|---------|-------------|
| `postgresql.enabled` | true | Enable internal PostgreSQL |
| `postgresql.storage.size` | 10Gi | Database storage size |
| `postgresql.storage.storageClass` | "" | Storage class name |
| `postgresql.auth.password` | "" | Database password (REQUIRED) |
### Agent Configuration
| Option | Default | Description |
|--------|---------|-------------|
| `agent.enabled` | true | Deploy agents |
| `agent.kind` | DaemonSet | DaemonSet or Deployment |
| `agent.replicas` | 1 | Replicas (Deployment only) |
| `agent.keyDir` | /var/lib/certctl/keys | Key storage directory |
### Issuer Configuration
| Option | Default | Description |
|--------|---------|-------------|
| `server.issuer.local.enabled` | true | Enable Local CA |
| `server.issuer.acme.enabled` | false | Enable ACME |
| `server.issuer.acme.directoryURL` | "" | ACME directory URL |
| `server.issuer.acme.email` | "" | ACME email |
| `server.issuer.acme.challengeType` | http-01 | Challenge type |
See `values.yaml` for complete configuration options.
## Helm Template Functions
Defined in `templates/_helpers.tpl`:
| Function | Purpose |
|----------|---------|
| `certctl.name` | Chart name |
| `certctl.fullname` | Full release name |
| `certctl.chart` | Chart name and version |
| `certctl.labels` | Common labels |
| `certctl.selectorLabels` | Selector labels |
| `certctl.serverSelectorLabels` | Server selector labels |
| `certctl.agentSelectorLabels` | Agent selector labels |
| `certctl.postgresSelectorLabels` | PostgreSQL selector labels |
| `certctl.serviceAccountName` | ServiceAccount name |
| `certctl.serverImage` | Server image URI |
| `certctl.agentImage` | Agent image URI |
| `certctl.postgresImage` | PostgreSQL image URI |
| `certctl.databaseURL` | Database connection string |
| `certctl.serverURL` | Server URL for agents |
## Security Features
### Pod Security
- Non-root users (UID 1000 for app, UID 999 for PostgreSQL)
- Read-only root filesystems
- No privilege escalation
- Dropped capabilities (ALL)
- Resource limits to prevent DoS
### Secrets Management
- All sensitive data in Kubernetes Secrets
- Base64 encoded at rest
- Can be integrated with:
- sealed-secrets
- external-secrets
- Vault
- AWS Secrets Manager
### RBAC
- ServiceAccount per release
- Optional ClusterRole/ClusterRoleBinding
- Extensible for custom permissions
### Network Security
- Support for Kubernetes NetworkPolicies
- Service-to-service communication via internal DNS
- Optional Ingress with TLS
## Monitoring and Observability
### Health Checks
- Liveness probes (detect dead containers)
- Readiness probes (detect not-ready services)
- HTTP endpoints: `/health`, `/readyz`
### Logging
- Structured JSON logging
- Request ID propagation
- Configurable log levels (debug, info, warn, error)
### Metrics
- Prometheus metrics endpoint: `/api/v1/metrics/prometheus`
- Optional ServiceMonitor for Prometheus Operator
- Built-in metrics:
- Certificate counts by status
- Agent counts and status
- Job completion/failure rates
- Server uptime
## Installation Quick Reference
```bash
# Development
helm install certctl certctl/ \
--set server.auth.apiKey=dev \
--set postgresql.auth.password=dev
# Production HA
helm install certctl certctl/ \
--values examples/values-prod-ha.yaml \
--set server.auth.apiKey="$(openssl rand -base64 32)" \
--set postgresql.auth.password="$(openssl rand -base64 32)"
# External database
helm install certctl certctl/ \
--values examples/values-external-db.yaml \
--set postgresql.enabled=false \
--set 'server.env.CERTCTL_DATABASE_URL=postgres://...'
# ACME with Let's Encrypt
helm install certctl certctl/ \
--set server.issuer.acme.enabled=true \
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory
# Check status
kubectl get pods -l app.kubernetes.io/instance=certctl
kubectl logs -l app.kubernetes.io/component=server -f
# Upgrade
helm upgrade certctl certctl/ -f new-values.yaml
# Uninstall
helm uninstall certctl
```
## Best Practices
### 1. Use Secrets Management
```bash
# Use sealed-secrets
kubectl create secret generic certctl-secrets \
--from-literal=api-key="$(openssl rand -base64 32)" \
--dry-run=client -o yaml | kubeseal -f - | kubectl apply -f -
```
### 2. Configure Resource Limits
Match limits to your cluster capacity:
```yaml
server:
resources:
requests: {cpu: 250m, memory: 256Mi}
limits: {cpu: 1000m, memory: 512Mi}
```
### 3. Enable HA for Production
```yaml
server:
replicas: 3
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution: [...]
```
### 4. Use Persistent Storage
```yaml
postgresql:
storage:
size: 100Gi
storageClass: fast-ssd
```
### 5. Enable Monitoring
```yaml
monitoring:
enabled: true
serviceMonitor:
enabled: true
```
## Documentation
- **README.md** - Complete Helm chart documentation
- **DEPLOYMENT_GUIDE.md** - Step-by-step deployment instructions
- **values.yaml** - Commented configuration reference
## Support
For issues, questions, or contributions:
- GitHub: https://github.com/shankar0123/certctl
- Documentation: https://github.com/shankar0123/certctl/tree/main/docs
## License
BSL-1.1 (Business Source License)
Converts to Apache 2.0 on March 28, 2033
+515
View File
@@ -0,0 +1,515 @@
# Certctl Helm Deployment Guide
Complete guide for deploying certctl on Kubernetes with Helm.
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Installation Methods](#installation-methods)
3. [Production Deployment](#production-deployment)
4. [Configuration Examples](#configuration-examples)
5. [Post-Deployment Setup](#post-deployment-setup)
6. [Monitoring and Logging](#monitoring-and-logging)
7. [Maintenance](#maintenance)
## Prerequisites
### Required Tools
```bash
# Verify Kubernetes cluster access
kubectl cluster-info
kubectl get nodes
# Install Helm (if not already installed)
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm version
# Verify Helm installation
helm repo list
```
### Kubernetes Requirements
- Kubernetes 1.19 or later
- At least 2GB available memory
- At least 10GB available storage (for PostgreSQL)
- Network policies support (optional, for security)
- Ingress controller (nginx, istio, etc.) - optional
### Create Namespace
```bash
# Create isolated namespace
kubectl create namespace certctl
# Set as default namespace
kubectl config set-context --current --namespace=certctl
# Label for network policies (optional)
kubectl label namespace certctl certctl-ns=true
```
## Installation Methods
### Method 1: Minimal Development Setup
Perfect for testing and development:
```bash
# Install with minimal configuration
helm install certctl certctl/certctl \
--namespace certctl \
--set server.auth.apiKey="dev-key-change-in-production" \
--set postgresql.auth.password="dev-password-change-in-production"
# Wait for deployment
kubectl rollout status deployment/certctl-server
kubectl rollout status statefulset/certctl-postgres
```
### Method 2: Production HA Setup
For production workloads:
```bash
# Generate secure credentials
API_KEY=$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -base64 32)
# Install with HA configuration
helm install certctl certctl/certctl \
--namespace certctl \
--values deploy/helm/examples/values-prod-ha.yaml \
--set server.auth.apiKey="$API_KEY" \
--set postgresql.auth.password="$DB_PASSWORD"
```
### Method 3: External PostgreSQL
Using managed database service:
```bash
# Install with external database
helm install certctl certctl/certctl \
--namespace certctl \
--values deploy/helm/examples/values-external-db.yaml \
--set server.auth.apiKey="$API_KEY" \
--set 'server.env.CERTCTL_DATABASE_URL=postgres://user:pass@db.example.com:5432/certctl?sslmode=require'
```
### Method 4: Using Custom values.yaml
Recommended for GitOps workflows:
```bash
# Create values file with secrets management
cat > /tmp/certctl-values.yaml <<EOF
server:
auth:
apiKey: "$API_KEY"
logging:
level: info
postgresql:
auth:
password: "$DB_PASSWORD"
storage:
size: 50Gi
agent:
enabled: true
kind: DaemonSet
ingress:
enabled: true
className: nginx
hosts:
- host: certctl.example.com
paths:
- path: /
pathType: Prefix
EOF
# Install using values file
helm install certctl certctl/certctl \
--namespace certctl \
--values /tmp/certctl-values.yaml
```
## Production Deployment
### Step 1: Prepare Environment
```bash
# Create namespace
kubectl create namespace certctl
cd deploy/helm
# Generate credentials
API_KEY=$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -base64 32)
echo "API Key: $API_KEY"
echo "DB Password: $DB_PASSWORD"
# Save credentials in secure location (e.g., 1Password, Vault, AWS Secrets Manager)
```
### Step 2: Prepare Storage
```bash
# List available storage classes
kubectl get storageclass
# If needed, create a high-performance storage class for production
cat <<EOF | kubectl apply -f -
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com # For AWS, adjust for your cloud provider
parameters:
type: gp3
iops: "3000"
throughput: "125"
EOF
```
### Step 3: Set Up TLS with cert-manager
```bash
# Install cert-manager (if not already installed)
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set installCRDs=true
# Create ClusterIssuer for Let's Encrypt
kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: nginx
EOF
```
### Step 4: Install Certctl
```bash
# Install using HA values
helm install certctl certctl/ \
--namespace certctl \
--values examples/values-prod-ha.yaml \
--set server.auth.apiKey="$API_KEY" \
--set postgresql.auth.password="$DB_PASSWORD" \
--set ingress.annotations."cert-manager\.io/cluster-issuer"=letsencrypt-prod \
--set ingress.hosts[0].host=certctl.example.com
# Verify installation
kubectl get all -l app.kubernetes.io/instance=certctl
```
### Step 5: Verify Deployment
```bash
# Check pod status
kubectl get pods -l app.kubernetes.io/instance=certctl
kubectl describe pods -l app.kubernetes.io/instance=certctl
# Check service status
kubectl get svc -l app.kubernetes.io/instance=certctl
# Check ingress status
kubectl get ingress
kubectl describe ingress certctl
# Test API connectivity
POD=$(kubectl get pods -l app.kubernetes.io/component=server -o jsonpath='{.items[0].metadata.name}')
kubectl port-forward $POD 8443:8443 &
curl -H "Authorization: Bearer $API_KEY" http://localhost:8443/health
```
### Step 6: Access the Dashboard
```bash
# Port forward to local machine
kubectl port-forward svc/certctl-server 8443:8443 &
# Or if using Ingress:
# Open browser: https://certctl.example.com
# Login with API key: $API_KEY
```
## Configuration Examples
### Example 1: ACME (Let's Encrypt)
```bash
helm install certctl certctl/ \
--set server.issuer.acme.enabled=true \
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory \
--set server.issuer.acme.email=admin@example.com \
--set server.issuer.acme.challengeType=http-01
```
### Example 2: DNS-01 (Wildcard Certs)
Requires DNS scripts ConfigMap:
```bash
# Create DNS scripts ConfigMap
kubectl create configmap dns-scripts \
--from-file=dns-present.sh=./scripts/dns-present.sh \
--from-file=dns-cleanup.sh=./scripts/dns-cleanup.sh
# Install with DNS-01
helm install certctl certctl/ \
--set server.issuer.acme.enabled=true \
--set server.issuer.acme.challengeType=dns-01 \
--values examples/values-acme-dns01.yaml
```
### Example 3: AWS RDS Database
```bash
helm install certctl certctl/ \
--set postgresql.enabled=false \
--set 'server.env.CERTCTL_DATABASE_URL=postgres://user:password@mydb.c9akciq32.us-east-1.rds.amazonaws.com:5432/certctl?sslmode=require'
```
### Example 4: Multiple Issuers
```bash
helm install certctl certctl/ \
--set server.issuer.local.enabled=true \
--set server.issuer.acme.enabled=true \
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory
```
### Example 5: Email Notifications
```bash
helm install certctl certctl/ \
--set server.smtp.enabled=true \
--set server.smtp.host=smtp.example.com \
--set server.smtp.port=587 \
--set server.smtp.username=alerts@example.com \
--set server.smtp.password="$SMTP_PASSWORD" \
--set server.smtp.fromAddress=certctl@example.com
```
## Post-Deployment Setup
### 1. Initial Database Setup
```bash
# Check database connection
POD=$(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}')
# Execute psql commands
kubectl exec -it $POD -- \
psql -U certctl -d certctl -c '\dt'
# View database status
kubectl logs $POD | tail -20
```
### 2. Create Default Certificates
```bash
# Port forward to API
kubectl port-forward svc/certctl-server 8443:8443 &
# Create a test certificate
API_KEY="your-api-key"
curl -X POST http://localhost:8443/api/v1/certificates \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"common_name": "test.example.com",
"sans": ["test.example.com", "*.example.com"],
"owner": "admin@example.com"
}'
```
### 3. Configure Agents
```bash
# Get agent names
kubectl get pods -l app.kubernetes.io/component=agent -o wide
# Check agent connectivity
POD=$(kubectl get pods -l app.kubernetes.io/component=agent -o jsonpath='{.items[0].metadata.name}')
kubectl logs $POD | grep -i heartbeat
```
### 4. Set Up HTTPS for Web Dashboard
The Ingress will handle TLS if configured properly:
```bash
# Verify ingress is ready
kubectl get ingress
kubectl describe ingress certctl
# Test HTTPS
curl https://certctl.example.com/health
```
## Monitoring and Logging
### 1. View Logs
```bash
# Server logs
kubectl logs -l app.kubernetes.io/component=server -f --all-containers=true
# PostgreSQL logs
kubectl logs -l app.kubernetes.io/component=postgres -f
# Agent logs
kubectl logs -l app.kubernetes.io/component=agent -f --all-containers=true
# Logs from all components
kubectl logs -l app.kubernetes.io/instance=certctl -f --all-containers=true
```
### 2. Install Prometheus Monitoring
```bash
# Install Prometheus operator (if not already installed)
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install prometheus prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--create-namespace
# Certctl will automatically expose metrics if monitoring.enabled=true
helm install certctl certctl/ \
--set monitoring.enabled=true \
--set monitoring.serviceMonitor.enabled=true
```
### 3. Set Up Alerts
```bash
# Create Prometheus alerts
cat <<EOF | kubectl apply -f -
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: certctl-alerts
spec:
groups:
- name: certctl
interval: 30s
rules:
- alert: CertctlServerDown
expr: up{job="certctl-server"} == 0
for: 5m
annotations:
summary: "Certctl server is down"
- alert: CertificateExpiringSoon
expr: certctl_certificate_expiring_soon > 0
for: 1h
annotations:
summary: "{{ \$value }} certificates expiring soon"
EOF
```
## Maintenance
### Scaling
```bash
# Scale server replicas
helm upgrade certctl certctl/ \
--set server.replicas=5
# Scale agents (Deployment kind only)
helm upgrade certctl certctl/ \
--set agent.kind=Deployment \
--set agent.replicas=10
```
### Updating
```bash
# Update chart version
helm repo update
helm upgrade certctl certctl/certctl \
--namespace certctl \
-f values.yaml
# Verify update
kubectl rollout status deployment/certctl-server
kubectl rollout status statefulset/certctl-postgres
```
### Backup and Restore
```bash
# Backup PostgreSQL data
kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') \
pg_dump -U certctl certctl | gzip > certctl-backup.sql.gz
# Restore from backup
zcat certctl-backup.sql.gz | kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') \
psql -U certctl certctl
# Backup PVC data
kubectl get pvc
kubectl exec -i $(kubectl get pods -l app.kubernetes.io/component=postgres -o jsonpath='{.items[0].metadata.name}') \
tar czf - /var/lib/postgresql/data | gzip > certctl-data-backup.tar.gz
```
### Uninstall
```bash
# Remove Helm release (keeps PVCs by default)
helm uninstall certctl --namespace certctl
# Delete PVCs if needed
kubectl delete pvc --all -n certctl
# Delete namespace
kubectl delete namespace certctl
```
## Troubleshooting
See [README.md](README.md#troubleshooting) for detailed troubleshooting steps.
Common commands:
```bash
# Get all resources
kubectl get all -n certctl
# Describe pod for events
kubectl describe pod <pod-name> -n certctl
# Stream logs
kubectl logs -f <pod-name> -n certctl
# Execute commands in pod
kubectl exec -it <pod-name> -n certctl -- /bin/sh
# Check events
kubectl get events -n certctl --sort-by='.lastTimestamp'
```
+234
View File
@@ -0,0 +1,234 @@
# Certctl Helm Chart - Complete File Index
## Navigation Guide
### Getting Started
1. **Start here**: `INSTALLATION.md` - Quick installation guide with one-liners
2. **Full reference**: `README.md` - Complete Helm chart documentation
3. **Detailed guide**: `DEPLOYMENT_GUIDE.md` - Step-by-step deployment walkthrough
4. **Architecture**: `CHART_SUMMARY.md` - Technical overview and design
### Chart Directory Structure
```
deploy/helm/
├── README.md Main documentation (15 KB)
├── DEPLOYMENT_GUIDE.md Step-by-step guide (12 KB)
├── CHART_SUMMARY.md Architecture & design (13 KB)
├── INSTALLATION.md Quick start (2.2 KB)
├── INDEX.md This file
├── certctl/ Helm chart package
│ ├── Chart.yaml Chart metadata
│ ├── values.yaml Default configuration (11 KB)
│ ├── .helmignore Build ignore patterns
│ │
│ └── templates/ 15 Kubernetes resource templates
│ ├── _helpers.tpl Helper functions
│ ├── NOTES.txt Post-install notes
│ ├── server-deployment.yaml API server
│ ├── server-service.yaml Server networking
│ ├── server-configmap.yaml Server configuration
│ ├── server-secret.yaml Server secrets
│ ├── postgres-statefulset.yaml Database
│ ├── postgres-service.yaml Database networking
│ ├── postgres-secret.yaml Database secrets
│ ├── agent-daemonset.yaml Agents (DaemonSet/Deployment)
│ ├── agent-configmap.yaml Agent configuration
│ ├── ingress.yaml Optional HTTPS ingress
│ └── serviceaccount.yaml RBAC resources
└── examples/ Example configurations
├── values-dev.yaml Development setup
├── values-prod-ha.yaml Production HA setup
├── values-external-db.yaml External PostgreSQL
└── values-acme-dns01.yaml ACME DNS-01 configuration
```
## File Descriptions
### Documentation Files
| File | Purpose | Size |
|------|---------|------|
| `README.md` | Complete Helm chart documentation, configuration reference, security considerations | 15 KB |
| `DEPLOYMENT_GUIDE.md` | Step-by-step installation instructions, production setup, troubleshooting | 12 KB |
| `CHART_SUMMARY.md` | Technical overview, architecture, features, best practices | 13 KB |
| `INSTALLATION.md` | Quick start guide, one-liner commands, verification steps | 2.2 KB |
| `INDEX.md` | This file - complete file index and navigation | - |
### Chart Files
| File | Purpose |
|------|---------|
| `Chart.yaml` | Helm chart metadata (name, version, appVersion, license) |
| `values.yaml` | Default configuration values with comprehensive comments |
| `.helmignore` | Files to ignore when building the chart |
### Template Files
| File | Components Created |
|------|-------------------|
| `_helpers.tpl` | 14 Helm template helper functions |
| `NOTES.txt` | Post-installation notes and instructions |
| `server-deployment.yaml` | Certctl API server deployment (1-N replicas) |
| `server-service.yaml` | Service exposing the server |
| `server-configmap.yaml` | Non-secret server configuration |
| `server-secret.yaml` | Secrets (API key, DB password, SMTP) |
| `postgres-statefulset.yaml` | PostgreSQL database with persistent storage |
| `postgres-service.yaml` | Headless service for PostgreSQL |
| `postgres-secret.yaml` | Database credentials |
| `agent-daemonset.yaml` | Certctl agents (DaemonSet or Deployment) |
| `agent-configmap.yaml` | Agent configuration |
| `ingress.yaml` | Optional HTTPS ingress resource |
| `serviceaccount.yaml` | ServiceAccount and RBAC resources |
### Example Configuration Files
| File | Use Case | Features |
|------|----------|----------|
| `values-dev.yaml` | Development/testing | Single replica, debug logging, LoadBalancer, no auth |
| `values-prod-ha.yaml` | Production HA | 3 replicas, pod anti-affinity, monitoring, large storage |
| `values-external-db.yaml` | External PostgreSQL | AWS RDS, Cloud SQL, Azure Database, self-managed |
| `values-acme-dns01.yaml` | Let's Encrypt | DNS-01 challenges, wildcard certs, custom DNS scripts |
## Quick Links
### Installation Commands
#### Development
```bash
helm install certctl certctl/ \
--set server.auth.type=none \
--set postgresql.auth.password=dev
```
#### Production HA
```bash
helm install certctl certctl/ \
--values examples/values-prod-ha.yaml \
--set server.auth.apiKey="$(openssl rand -base64 32)" \
--set postgresql.auth.password="$(openssl rand -base64 32)"
```
#### External Database
```bash
helm install certctl certctl/ \
--values examples/values-external-db.yaml \
--set postgresql.enabled=false \
--set 'server.env.CERTCTL_DATABASE_URL=postgres://...'
```
### Verification Commands
```bash
# Check chart syntax
helm lint certctl/
helm template certctl certctl/
# Install in cluster
helm install certctl certctl/
helm status certctl
# Check pod status
kubectl get pods -l app.kubernetes.io/instance=certctl
# View logs
kubectl logs -l app.kubernetes.io/component=server -f
```
## Documentation Organization
### By User Role
**DevOps/Platform Engineers**
- Start: `INSTALLATION.md`
- Deep dive: `DEPLOYMENT_GUIDE.md`
- Configuration reference: `README.md`
**Kubernetes Developers**
- Architecture: `CHART_SUMMARY.md`
- Configuration: `values.yaml`
- Templates: `templates/`
**Security/SREs**
- Security section: `README.md#security-considerations`
- RBAC: `templates/serviceaccount.yaml`
- Network policies: `DEPLOYMENT_GUIDE.md#network-policies`
**Database Administrators**
- PostgreSQL config: `values.yaml` (postgresql section)
- External DB setup: `examples/values-external-db.yaml`
- Backup/restore: `DEPLOYMENT_GUIDE.md#backup-and-restore`
### By Task
**Getting Started**
1. Read: `INSTALLATION.md`
2. Install: `helm install certctl certctl/`
3. Verify: Run commands in `INSTALLATION.md`
**Production Deployment**
1. Read: `DEPLOYMENT_GUIDE.md`
2. Choose: `examples/values-prod-ha.yaml`
3. Deploy: Follow step-by-step guide
4. Reference: `README.md` for detailed options
**Troubleshooting**
- Common issues: `README.md#troubleshooting`
- Detailed guide: `DEPLOYMENT_GUIDE.md#troubleshooting`
- Error messages: kubectl logs and events
**Configuration**
- All options: `values.yaml`
- Examples: `examples/values-*.yaml`
- Detailed docs: `README.md#configuration`
## Key Features
### High Availability
- Multi-replica server deployment
- Pod anti-affinity
- StatefulSet for database
- Pod disruption budgets
### Security
- Non-root containers
- Read-only filesystems
- RBAC support
- Kubernetes Secrets
- Network policies
### Flexibility
- Multiple issuers (Local CA, ACME, step-ca, OpenSSL)
- Internal or external PostgreSQL
- DaemonSet or Deployment agents
- Optional Ingress with TLS
- Email notifications
### Observability
- Health checks
- Structured logging
- Prometheus metrics
- ServiceMonitor support
## Support
- **GitHub**: https://github.com/shankar0123/certctl
- **Issues**: Report on GitHub issues
- **Documentation**: All docs are in `deploy/helm/`
## File Statistics
- **Total files**: 24
- **Documentation**: 4 files (42 KB)
- **Chart files**: 3 files
- **Templates**: 13 files
- **Examples**: 4 files
- **Total size**: 144 KB
## License
All files are covered under the BSL-1.1 license (converts to Apache 2.0 in 2033).
+95
View File
@@ -0,0 +1,95 @@
# Quick Installation Guide
## One-Liner Installation
### Development (no auth)
```bash
helm install certctl certctl/ \
--set server.auth.type=none \
--set postgresql.auth.password=dev
```
### Production (with API key)
```bash
API_KEY=$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -base64 32)
helm install certctl certctl/ \
--values examples/values-prod-ha.yaml \
--set server.auth.apiKey="$API_KEY" \
--set postgresql.auth.password="$DB_PASSWORD"
```
## Verify Installation
```bash
# Wait for pods to be ready
kubectl rollout status deployment/certctl-server
kubectl rollout status statefulset/certctl-postgres
# Check all components
kubectl get pods -l app.kubernetes.io/instance=certctl
# View server logs
kubectl logs -l app.kubernetes.io/component=server -f
# Access the API
kubectl port-forward svc/certctl-server 8443:8443 &
curl http://localhost:8443/health
```
## Next Steps
1. **Read Documentation**
- `README.md` - Complete reference
- `DEPLOYMENT_GUIDE.md` - Step-by-step guide
- `CHART_SUMMARY.md` - Architecture overview
2. **Configure for Your Environment**
- Review `examples/` for your deployment scenario
- Customize `values.yaml` as needed
- Use `helm upgrade` to apply changes
3. **Set Up Monitoring**
- Install Prometheus (optional)
- Enable Ingress with HTTPS
- Configure email notifications
4. **Deploy Agents**
- Agents deploy automatically as DaemonSet
- Verify with: `kubectl get pods -l app.kubernetes.io/component=agent`
5. **Create Certificates**
- Configure issuer connectors (Local CA, ACME, etc.)
- Access web dashboard at ingress or port-forward
## Common Commands
```bash
# List installations
helm list
# View chart values
helm values certctl
# Upgrade chart
helm upgrade certctl certctl/ -f new-values.yaml
# Rollback to previous version
helm rollback certctl 1
# Uninstall chart
helm uninstall certctl
# View deployment history
helm history certctl
# Dry-run installation to see generated YAML
helm install certctl certctl/ --dry-run --debug
```
## Support
- Full documentation in `README.md`
- Troubleshooting in `DEPLOYMENT_GUIDE.md`
- Issues: https://github.com/shankar0123/certctl
+516
View File
@@ -0,0 +1,516 @@
# Certctl Helm Chart
Production-ready Helm chart for deploying certctl (self-hosted certificate lifecycle management platform) on Kubernetes.
## Table of Contents
1. [Quick Start](#quick-start)
2. [Chart Features](#chart-features)
3. [Prerequisites](#prerequisites)
4. [Installation](#installation)
5. [Configuration](#configuration)
6. [Usage Examples](#usage-examples)
7. [Upgrading](#upgrading)
8. [Uninstalling](#uninstalling)
9. [Architecture](#architecture)
10. [Security Considerations](#security-considerations)
11. [Troubleshooting](#troubleshooting)
## Quick Start
```bash
# Add the chart repository (when available)
helm repo add certctl https://charts.example.com
helm repo update
# Install with default values
helm install certctl certctl/certctl \
--set server.auth.apiKey="your-secure-api-key" \
--set postgresql.auth.password="your-secure-password"
# Check installation status
kubectl get pods -l app.kubernetes.io/instance=certctl
```
## Chart Features
- **Server Deployment** — certctl control plane with configurable replicas
- **PostgreSQL StatefulSet** — Persistent database with automatic schema migration
- **Agent DaemonSet or Deployment** — Flexible agent deployment (per-node or custom replicas)
- **Ingress Support** — Optional HTTPS ingress with cert-manager integration
- **Security Contexts** — Non-root containers, read-only filesystems, minimal capabilities
- **Resource Limits** — Configurable CPU and memory requests/limits
- **Health Checks** — Liveness and readiness probes on all containers
- **ConfigMaps and Secrets** — Centralized configuration management
- **Service Account and RBAC** — Optional cluster role bindings
- **Pod Disruption Budgets** — HA-ready with configurable disruption budgets
- **Monitoring** — Optional Prometheus ServiceMonitor support
## Prerequisites
- Kubernetes 1.19 or later
- Helm 3.0 or later
- Optional: cert-manager (for automatic TLS certificate provisioning)
- Optional: Prometheus (for metrics scraping)
## Installation
### 1. Using Chart from Repository
```bash
helm repo add certctl https://charts.example.com
helm repo update
helm install certctl certctl/certctl -f my-values.yaml
```
### 2. Using Local Chart
```bash
cd deploy/helm
helm install certctl certctl/ \
--set server.auth.apiKey="$(openssl rand -base64 32)" \
--set postgresql.auth.password="$(openssl rand -base64 32)"
```
### 3. Minimal Production Installation
```bash
helm install certctl certctl/certctl \
--namespace certctl \
--create-namespace \
--set server.auth.apiKey="change-me" \
--set postgresql.auth.password="change-me" \
--set server.replicas=2 \
--set server.resources.requests.cpu=200m \
--set server.resources.requests.memory=256Mi \
--set ingress.enabled=true \
--set ingress.className=nginx \
--set ingress.hosts[0].host=certctl.example.com
```
## Configuration
### Server Configuration
```yaml
server:
replicas: 1 # Number of server replicas
port: 8443 # Service port
auth:
type: api-key # Authentication type
apiKey: "your-api-key" # REQUIRED for production
logging:
level: info # Log level (debug, info, warn, error)
format: json # Output format
issuer:
local:
enabled: true # Enable local CA issuer
acme:
enabled: false # Enable ACME issuer
directoryURL: "" # ACME directory URL
email: "" # ACME registration email
challengeType: "http-01" # Challenge type (http-01, dns-01, dns-persist-01)
```
### PostgreSQL Configuration
```yaml
postgresql:
enabled: true # Use managed PostgreSQL
auth:
database: certctl
username: certctl
password: "your-password" # REQUIRED
storage:
size: 10Gi # PVC size
storageClass: "" # Use default StorageClass
```
### Agent Configuration
```yaml
agent:
enabled: true # Deploy agents
kind: DaemonSet # DaemonSet (one per node) or Deployment
replicas: 1 # For Deployment kind only
discoveryDirs: "" # Comma-separated cert discovery paths
nodeSelector: {} # Node affinity for DaemonSet
```
### Ingress Configuration
```yaml
ingress:
enabled: false
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: certctl.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: certctl-tls
hosts:
- certctl.example.com
```
See `values.yaml` for all available configuration options.
## Usage Examples
### Example 1: High Availability Setup
```yaml
# ha-values.yaml
server:
replicas: 3
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
postgresql:
storage:
size: 50Gi
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app.kubernetes.io/component
operator: In
values: [server]
topologyKey: kubernetes.io/hostname
```
Deploy with:
```bash
helm install certctl certctl/certctl -f ha-values.yaml
```
### Example 2: External PostgreSQL Database
```yaml
# external-db-values.yaml
postgresql:
enabled: false
server:
env:
CERTCTL_DATABASE_URL: "postgres://user:password@rds.example.com:5432/certctl?sslmode=require"
```
Deploy with:
```bash
helm install certctl certctl/certctl -f external-db-values.yaml
```
### Example 3: ACME + Let's Encrypt
```yaml
# acme-values.yaml
server:
issuer:
acme:
enabled: true
directoryURL: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
challengeType: dns-01
dnsPresentScript: /scripts/dns-present.sh
dnsCleanupScript: /scripts/dns-cleanup.sh
dnsPropagationWait: 30s
```
### Example 4: Email Notifications via Slack + SMTP
```yaml
# notifications-values.yaml
server:
smtp:
enabled: true
host: smtp.example.com
port: 587
username: certctl@example.com
password: "smtp-password"
fromAddress: certctl@example.com
useTLS: true
notifiers:
slack:
enabled: true
webhookUrl: https://hooks.slack.com/services/YOUR/WEBHOOK/URL
channel: "#certificates"
```
## Upgrading
```bash
# Update chart repository
helm repo update
# Upgrade release
helm upgrade certctl certctl/certctl -f values.yaml
# View upgrade history
helm history certctl
# Rollback to previous version
helm rollback certctl 1
```
## Uninstalling
```bash
# Delete the release (keeps data by default)
helm uninstall certctl
# Also delete persistent data
kubectl delete pvc --all -l app.kubernetes.io/instance=certctl
# Delete namespace
kubectl delete namespace certctl
```
## Architecture
### Components
```
┌──────────────────────────────────────────────────────────────┐
│ Kubernetes Cluster │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Ingress/LB │ │ Agent Pod 1 │ │
│ │ (optional) │ │ (DaemonSet) │ │
│ └────────┬────────┘ └──────────────────┘ │
│ │ │
│ ▼ ┌──────────────────┐ │
│ ┌─────────────────────────┐ │ Agent Pod 2 │ │
│ │ Server Deployment │ │ (DaemonSet) │ │
│ │ (1 to N replicas) │ └──────────────────┘ │
│ │ - REST API │ │
│ │ - Scheduler │ ┌──────────────────┐ │
│ │ - UI Dashboard │ │ Agent Pod N │ │
│ └────────┬────────────────┘ │ (DaemonSet) │ │
│ │ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ PostgreSQL StatefulSet │ │
│ │ - Database │ │
│ │ - PVC (persistent) │ │
│ └──────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
```
### Network Communication
- **Server → PostgreSQL**: Internal cluster DNS (`certctl-postgres:5432`)
- **Agent → Server**: Internal cluster DNS (`certctl-server:8443`)
- **External → Server**: Via Ingress or Service (ClusterIP/LoadBalancer/NodePort)
## Security Considerations
### 1. Secrets Management
All sensitive data is stored in Kubernetes Secrets:
- PostgreSQL credentials
- API keys
- SMTP passwords
- ACME account secrets
**Best Practices:**
- Use sealed-secrets or external-secrets operator
- Enable encryption at rest in etcd
- Rotate secrets regularly
```bash
# Example: Using sealed-secrets
kubectl create secret generic certctl-api-key --from-literal=api-key="$(openssl rand -base64 32)" --dry-run=client -o yaml | kubeseal -f - | kubectl apply -f -
```
### 2. RBAC
The chart creates minimal RBAC by default:
- ServiceAccount per release
- ClusterRole (empty, extensible)
- ClusterRoleBinding
**To restrict further:**
```yaml
rbac:
create: true
# Add specific rules here
```
### 3. Pod Security
All containers run with:
- Non-root user (UID 1000)
- Read-only root filesystem
- No privilege escalation
- Dropped capabilities (ALL)
### 4. Network Policies
Restrict pod-to-pod communication:
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: certctl-default-deny
spec:
podSelector:
matchLabels:
app.kubernetes.io/instance: certctl
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: certctl
egress:
- to:
- namespaceSelector:
matchLabels:
name: certctl
- to:
- podSelector: {}
ports:
- protocol: TCP
port: 53 # DNS
- protocol: UDP
port: 53
```
### 5. TLS/HTTPS
Enable HTTPS with cert-manager:
```bash
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set installCRDs=true
```
Then configure Ingress with TLS.
### 6. API Key Security
For production:
1. Generate a strong API key: `openssl rand -base64 32`
2. Store securely (Vault, sealed-secrets, etc.)
3. Never commit to Git
4. Rotate periodically
```bash
# Generate and deploy API key
NEW_KEY=$(openssl rand -base64 32)
kubectl patch secret certctl-server -p "{\"data\":{\"api-key\":\"$(echo -n $NEW_KEY | base64)\"}}"
```
## Troubleshooting
### 1. Pods Not Starting
```bash
# Check pod status
kubectl get pods -l app.kubernetes.io/instance=certctl
kubectl describe pod <pod-name>
kubectl logs <pod-name>
```
### 2. Database Connection Issues
```bash
# Verify PostgreSQL is running
kubectl get pods -l app.kubernetes.io/component=postgres
kubectl logs -l app.kubernetes.io/component=postgres
# Test connection from server pod
kubectl exec -it <server-pod> -- \
psql postgres://certctl:password@certctl-postgres:5432/certctl
```
### 3. Agent Not Connecting
```bash
# Check agent logs
kubectl logs -l app.kubernetes.io/component=agent
# Verify server is reachable
kubectl exec -it <agent-pod> -- \
wget -q -O - http://certctl-server:8443/health
```
### 4. Persistent Data Loss
```bash
# Check PVC status
kubectl get pvc
# Verify data is being stored
kubectl exec -it <postgres-pod> -- \
ls -lah /var/lib/postgresql/data/postgres
```
### 5. Permission Denied Errors
The chart runs containers as non-root (UID 1000). If you see permission errors:
```yaml
# Temporarily allow root for debugging
server:
securityContext:
runAsUser: 0 # NOT FOR PRODUCTION
```
### 6. Out of Memory
Increase resource limits:
```bash
helm upgrade certctl certctl/certctl \
--set server.resources.limits.memory=1Gi \
--set postgresql.resources.limits.memory=2Gi
```
### 7. Certificate Validation Issues
For self-signed certificates:
```bash
kubectl exec -it <pod> -- \
CERTCTL_TLS_INSECURE_SKIP_VERIFY=true <command>
```
### Common Issues and Solutions
| Issue | Solution |
|-------|----------|
| `ImagePullBackOff` | Update `server.image.repository` to your registry |
| `CrashLoopBackOff` | Check logs with `kubectl logs <pod>` |
| `Pending` PVC | Check storage class availability |
| Connection timeout | Verify network policies and service DNS |
| High memory usage | Adjust `postgresql.resources.limits` and `server.resources.limits` |
## Support and Contributing
For issues, questions, or contributions, visit:
- GitHub: https://github.com/shankar0123/certctl
- Documentation: https://github.com/shankar0123/certctl/tree/main/docs
## License
BSL-1.1 (converts to Apache 2.0 in 2033)
+31
View File
@@ -0,0 +1,31 @@
# Patterns to ignore when building packages.
# This supports shell glob patterns, relative path patterns, and negated
# patterns. Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.swo
*~
*.pyo
*.pyc
.pytest_cache/
*.egg-info/
dist/
build/
# IDE
.vscode/
.idea/
*.sublime-project
*.sublime-workspace
# OS
Thumbs.db
# Helm
Chart.lock
+20
View File
@@ -0,0 +1,20 @@
apiVersion: v2
name: certctl
description: Self-hosted certificate lifecycle management platform
type: application
version: 0.1.0
appVersion: "2.1.0"
keywords:
- certificate
- tls
- ssl
- pki
- acme
- lifecycle
- kubernetes
maintainers:
- name: certctl
home: https://github.com/shankar0123/certctl
sources:
- https://github.com/shankar0123/certctl
license: BSL-1.1
+68
View File
@@ -0,0 +1,68 @@
1. Get the certctl Server URL by running:
{{- if .Values.ingress.enabled }}
https://{{ index .Values.ingress.hosts 0 "host" }}
{{- else if contains "NodePort" .Values.server.service.type }}
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "certctl.fullname" . }}-server)
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.server.service.type }}
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-server --template "{.status.loadBalancer.ingress[0].ip}")
echo http://$SERVICE_IP:{{ .Values.server.service.port }}
{{- else }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/instance={{ .Release.Name }},app.kubernetes.io/component=server" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}
2. Get the default API key:
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-server -o jsonpath="{.data.api-key}" | base64 --decode; echo
3. Get PostgreSQL connection details:
Host: {{ include "certctl.fullname" . }}-postgres.{{ .Release.Namespace }}.svc.cluster.local
Port: 5432
Database: {{ .Values.postgresql.auth.database }}
Username: {{ .Values.postgresql.auth.username }}
Password: $(kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-postgres -o jsonpath="{.data.password}" | base64 --decode)
4. Check deployment status:
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }}
5. View server logs:
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=server -f
{{- if .Values.agent.enabled }}
6. View agent logs:
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=agent -f
{{- end }}
IMPORTANT NOTES FOR PRODUCTION:
1. Update the API key for security:
kubectl patch secret {{ include "certctl.fullname" . }}-server -n {{ .Release.Namespace }} \
-p '{"data":{"api-key":"'$(echo -n "YOUR_NEW_API_KEY" | base64)'"}}'
2. Update PostgreSQL password:
kubectl patch secret {{ include "certctl.fullname" . }}-postgres -n {{ .Release.Namespace }} \
-p '{"data":{"password":"'$(echo -n "YOUR_NEW_PASSWORD" | base64)'"}}'
3. Configure certificate issuers (ACME, step-ca, etc.) via values.yaml:
helm upgrade {{ .Release.Name }} certctl/certctl \
--set server.issuer.acme.enabled=true \
--set server.issuer.acme.directoryURL=https://acme-v02.api.letsencrypt.org/directory \
--set server.issuer.acme.email=admin@example.com
4. For production with persistent databases and backups:
- Use an external PostgreSQL managed service (AWS RDS, Cloud SQL, etc.)
- Set postgresql.enabled=false and configure CERTCTL_DATABASE_URL in values
5. Enable HTTPS/TLS using an Ingress with certificate management:
- Configure cert-manager for automatic TLS certificate renewal
- Update ingress values with your domain and certificate issuer
6. Review security contexts and network policies:
- All containers run as non-root
- Implement network policies to restrict traffic between components
- Consider pod security policies or security standards for your cluster
+125
View File
@@ -0,0 +1,125 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "certctl.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "certctl.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "certctl.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "certctl.labels" -}}
helm.sh/chart: {{ include "certctl.chart" . }}
{{ include "certctl.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- with .Values.commonLabels }}
{{ toYaml . }}
{{- end }}
{{- end }}
{{/*
Selector labels for the main service (server, agent, postgres)
*/}}
{{- define "certctl.selectorLabels" -}}
app.kubernetes.io/name: {{ include "certctl.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Server selector labels
*/}}
{{- define "certctl.serverSelectorLabels" -}}
{{ include "certctl.selectorLabels" . }}
app.kubernetes.io/component: server
{{- end }}
{{/*
Agent selector labels
*/}}
{{- define "certctl.agentSelectorLabels" -}}
{{ include "certctl.selectorLabels" . }}
app.kubernetes.io/component: agent
{{- end }}
{{/*
PostgreSQL selector labels
*/}}
{{- define "certctl.postgresSelectorLabels" -}}
{{ include "certctl.selectorLabels" . }}
app.kubernetes.io/component: postgres
{{- end }}
{{/*
Service account name
*/}}
{{- define "certctl.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "certctl.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Server image
*/}}
{{- define "certctl.serverImage" -}}
{{- $image := .Values.server.image }}
{{- printf "%s:%s" $image.repository (coalesce $image.tag .Chart.AppVersion) }}
{{- end }}
{{/*
Agent image
*/}}
{{- define "certctl.agentImage" -}}
{{- $image := .Values.agent.image }}
{{- printf "%s:%s" $image.repository (coalesce $image.tag .Chart.AppVersion) }}
{{- end }}
{{/*
PostgreSQL image
*/}}
{{- define "certctl.postgresImage" -}}
{{- $image := .Values.postgresql.image }}
{{- printf "%s:%s" $image.repository $image.tag }}
{{- end }}
{{/*
Database connection string
*/}}
{{- define "certctl.databaseURL" -}}
postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
{{- end }}
{{/*
Server URL (for agents)
*/}}
{{- define "certctl.serverURL" -}}
http://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
{{- end }}
@@ -0,0 +1,13 @@
{{- if .Values.agent.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "certctl.fullname" . }}-agent
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: agent
data:
{{- if .Values.agent.discoveryDirs }}
discovery-dirs: {{ .Values.agent.discoveryDirs | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,162 @@
{{- if .Values.agent.enabled }}
{{- if eq .Values.agent.kind "DaemonSet" }}
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: {{ include "certctl.fullname" . }}-agent
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: agent
spec:
selector:
matchLabels:
{{- include "certctl.agentSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "certctl.agentSelectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.agent.securityContext | nindent 8 }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.agent.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.agent.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.agent.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: agent
image: {{ include "certctl.agentImage" . }}
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
env:
- name: CERTCTL_SERVER_URL
value: {{ include "certctl.serverURL" . }}
- name: CERTCTL_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: api-key
- name: CERTCTL_AGENT_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: CERTCTL_KEY_DIR
value: {{ .Values.agent.keyDir }}
{{- if .Values.agent.discoveryDirs }}
- name: CERTCTL_DISCOVERY_DIRS
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-agent
key: discovery-dirs
{{- end }}
{{- with .Values.agent.env }}
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.agent.resources | nindent 12 }}
volumeMounts:
- name: agent-keys
mountPath: {{ .Values.agent.keyDir }}
- name: tmp
mountPath: /tmp
volumes:
- name: agent-keys
emptyDir:
sizeLimit: 1Gi
- name: tmp
emptyDir: {}
{{- else if eq .Values.agent.kind "Deployment" }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "certctl.fullname" . }}-agent
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: agent
spec:
replicas: {{ .Values.agent.replicas }}
selector:
matchLabels:
{{- include "certctl.agentSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "certctl.agentSelectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.agent.securityContext | nindent 8 }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.agent.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.agent.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.agent.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: agent
image: {{ include "certctl.agentImage" . }}
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
env:
- name: CERTCTL_SERVER_URL
value: {{ include "certctl.serverURL" . }}
- name: CERTCTL_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: api-key
- name: CERTCTL_AGENT_NAME
{{- if .Values.agent.name }}
value: {{ .Values.agent.name | quote }}
{{- else }}
valueFrom:
fieldRef:
fieldPath: metadata.name
{{- end }}
- name: CERTCTL_KEY_DIR
value: {{ .Values.agent.keyDir }}
{{- if .Values.agent.discoveryDirs }}
- name: CERTCTL_DISCOVERY_DIRS
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-agent
key: discovery-dirs
{{- end }}
{{- with .Values.agent.env }}
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.agent.resources | nindent 12 }}
volumeMounts:
- name: agent-keys
mountPath: {{ .Values.agent.keyDir }}
- name: tmp
mountPath: /tmp
volumes:
- name: agent-keys
emptyDir:
sizeLimit: 1Gi
- name: tmp
emptyDir: {}
{{- end }}
{{- end }}
@@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "certctl.fullname" . }}
labels:
{{- include "certctl.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "certctl.fullname" . }}-server
port:
number: {{ $.Values.server.service.port }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "certctl.fullname" . }}-postgres
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
type: Opaque
stringData:
password: {{ .Values.postgresql.auth.password | default "changeme" | quote }}
username: {{ .Values.postgresql.auth.username | quote }}
database: {{ .Values.postgresql.auth.database | quote }}
@@ -0,0 +1,18 @@
{{- if .Values.postgresql.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "certctl.fullname" . }}-postgres
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
spec:
clusterIP: None
ports:
- port: {{ .Values.postgresql.service.port }}
targetPort: postgres
protocol: TCP
name: postgres
selector:
{{- include "certctl.postgresSelectorLabels" . | nindent 4 }}
{{- end }}
@@ -0,0 +1,79 @@
{{- if .Values.postgresql.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "certctl.fullname" . }}-postgres
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
spec:
serviceName: {{ include "certctl.fullname" . }}-postgres
replicas: 1
selector:
matchLabels:
{{- include "certctl.postgresSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "certctl.postgresSelectorLabels" . | nindent 8 }}
spec:
securityContext:
{{- toYaml .Values.postgresql.securityContext | nindent 8 }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: postgres
image: {{ include "certctl.postgresImage" . }}
imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }}
ports:
- name: postgres
containerPort: 5432
protocol: TCP
env:
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: {{ include "certctl.fullname" . }}-postgres
key: database
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: {{ include "certctl.fullname" . }}-postgres
key: username
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "certctl.fullname" . }}-postgres
key: password
- name: POSTGRES_INITDB_ARGS
value: "--encoding=UTF8"
livenessProbe:
{{- toYaml .Values.postgresql.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.postgresql.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.postgresql.resources | nindent 12 }}
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
subPath: postgres
- name: postgres-init
mountPath: /docker-entrypoint-initdb.d
volumes:
- name: postgres-init
emptyDir: {}
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes:
- ReadWriteOnce
{{- if .Values.postgresql.storage.storageClass }}
storageClassName: {{ .Values.postgresql.storage.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgresql.storage.size }}
{{- end }}
@@ -0,0 +1,36 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "certctl.fullname" . }}-server
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: server
data:
log-level: {{ .Values.server.logging.level | quote }}
auth-type: {{ .Values.server.auth.type | quote }}
keygen-mode: {{ .Values.server.keygen.mode | quote }}
rate-limit-rps: {{ .Values.server.rateLimiting.rps | quote }}
rate-limit-burst: {{ .Values.server.rateLimiting.burst | quote }}
{{- if .Values.server.cors.origins }}
cors-origins: {{ .Values.server.cors.origins | quote }}
{{- end }}
{{- if .Values.server.networkScan.enabled }}
network-scan-interval: {{ .Values.server.networkScan.interval | quote }}
{{- end }}
{{- if .Values.server.est.enabled }}
est-issuer-id: {{ .Values.server.est.issuerID | quote }}
{{- if .Values.server.est.profileID }}
est-profile-id: {{ .Values.server.est.profileID | quote }}
{{- end }}
{{- end }}
{{- if .Values.server.smtp.enabled }}
smtp-host: {{ .Values.server.smtp.host | quote }}
smtp-port: {{ .Values.server.smtp.port | quote }}
smtp-username: {{ .Values.server.smtp.username | quote }}
smtp-from-address: {{ .Values.server.smtp.fromAddress | quote }}
{{- end }}
{{- if .Values.server.issuer.acme.enabled }}
acme-directory-url: {{ .Values.server.issuer.acme.directoryURL | quote }}
acme-email: {{ .Values.server.issuer.acme.email | quote }}
acme-challenge-type: {{ .Values.server.issuer.acme.challengeType | quote }}
{{- end }}
@@ -0,0 +1,196 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "certctl.fullname" . }}-server
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: server
spec:
{{- if gt (int .Values.server.replicas) 1 }}
replicas: {{ .Values.server.replicas }}
{{- end }}
selector:
matchLabels:
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "certctl.serverSelectorLabels" . | nindent 8 }}
annotations:
checksum/config: {{ include (print $.Template.BasePath "/server-configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/server-secret.yaml") . | sha256sum }}
spec:
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.server.securityContext | nindent 8 }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: server
image: {{ include "certctl.serverImage" . }}
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.server.port }}
protocol: TCP
env:
- name: CERTCTL_SERVER_HOST
value: "0.0.0.0"
- name: CERTCTL_SERVER_PORT
value: "{{ .Values.server.port }}"
- name: CERTCTL_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: database-url
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "certctl.fullname" . }}-postgres
key: password
- name: CERTCTL_LOG_LEVEL
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: log-level
- name: CERTCTL_LOG_FORMAT
value: "json"
- name: CERTCTL_AUTH_TYPE
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: auth-type
{{- if eq .Values.server.auth.type "api-key" }}
- name: CERTCTL_AUTH_SECRET
valueFrom:
secretKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: api-key
{{- end }}
- name: CERTCTL_KEYGEN_MODE
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: keygen-mode
- name: CERTCTL_RATE_LIMIT_RPS
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: rate-limit-rps
- name: CERTCTL_RATE_LIMIT_BURST
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: rate-limit-burst
{{- if .Values.server.cors.origins }}
- name: CERTCTL_CORS_ORIGINS
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: cors-origins
{{- end }}
{{- if .Values.server.networkScan.enabled }}
- name: CERTCTL_NETWORK_SCAN_ENABLED
value: "true"
- name: CERTCTL_NETWORK_SCAN_INTERVAL
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: network-scan-interval
{{- end }}
{{- if .Values.server.est.enabled }}
- name: CERTCTL_EST_ENABLED
value: "true"
- name: CERTCTL_EST_ISSUER_ID
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: est-issuer-id
{{- if .Values.server.est.profileID }}
- name: CERTCTL_EST_PROFILE_ID
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: est-profile-id
{{- end }}
{{- end }}
{{- if .Values.server.smtp.enabled }}
- name: CERTCTL_SMTP_HOST
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: smtp-host
- name: CERTCTL_SMTP_PORT
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: smtp-port
- name: CERTCTL_SMTP_USERNAME
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: smtp-username
- name: CERTCTL_SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: smtp-password
- name: CERTCTL_SMTP_FROM_ADDRESS
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: smtp-from-address
{{- end }}
{{- if .Values.server.issuer.acme.enabled }}
- name: CERTCTL_ACME_DIRECTORY_URL
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: acme-directory-url
- name: CERTCTL_ACME_EMAIL
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: acme-email
- name: CERTCTL_ACME_CHALLENGE_TYPE
valueFrom:
configMapKeyRef:
name: {{ include "certctl.fullname" . }}-server
key: acme-challenge-type
{{- end }}
{{- with .Values.server.env }}
{{- toYaml . | nindent 12 }}
{{- end }}
livenessProbe:
{{- toYaml .Values.server.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.server.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.server.resources | nindent 12 }}
volumeMounts:
- name: tmp
mountPath: /tmp
{{- if .Values.server.volumeMounts }}
{{- toYaml .Values.server.volumeMounts | nindent 12 }}
{{- end }}
volumes:
- name: tmp
emptyDir: {}
{{- if .Values.server.volumes }}
{{- toYaml .Values.server.volumes | nindent 8 }}
{{- end }}
{{- if .Values.nodeAffinity }}
affinity:
nodeAffinity:
{{- toYaml .Values.nodeAffinity | nindent 10 }}
{{- else if .Values.podAntiAffinity }}
affinity:
podAntiAffinity:
{{- toYaml .Values.podAntiAffinity | nindent 10 }}
{{- else if .Values.podAffinity }}
affinity:
podAffinity:
{{- toYaml .Values.podAffinity | nindent 10 }}
{{- end }}
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "certctl.fullname" . }}-server
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: server
type: Opaque
stringData:
database-url: postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
{{- if and (eq .Values.server.auth.type "api-key") .Values.server.auth.apiKey }}
api-key: {{ .Values.server.auth.apiKey | quote }}
{{- end }}
{{- if .Values.server.smtp.enabled }}
smtp-password: {{ .Values.server.smtp.password | quote }}
{{- end }}
@@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "certctl.fullname" . }}-server
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: server
{{- with .Values.server.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.server.service.type }}
ports:
- port: {{ .Values.server.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "certctl.serverSelectorLabels" . | nindent 4 }}
@@ -0,0 +1,37 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "certctl.serviceAccountName" . }}
labels:
{{- include "certctl.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- if .Values.rbac.create }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: {{ include "certctl.fullname" . }}
labels:
{{- include "certctl.labels" . | nindent 4 }}
rules: []
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ include "certctl.fullname" . }}
labels:
{{- include "certctl.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: {{ include "certctl.fullname" . }}
subjects:
- kind: ServiceAccount
name: {{ include "certctl.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
{{- end }}
+434
View File
@@ -0,0 +1,434 @@
# Default values for certctl Helm chart
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
# Namespace override (optional)
namespace: ""
# Global configuration
commonLabels: {}
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# ==============================================================================
# Certctl Server Configuration
# ==============================================================================
server:
# Number of replicas (for HA deployments)
replicas: 1
# Image configuration
image:
repository: ghcr.io/shankar0123/certctl
tag: "" # defaults to Chart.appVersion
pullPolicy: IfNotPresent
# Server port
port: 8443
# Resource requests and limits
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
# Pod security context
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
# Liveness and readiness probes
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
# Service type (ClusterIP, LoadBalancer, NodePort)
service:
type: ClusterIP
port: 8443
annotations: {}
# Authentication configuration
auth:
type: api-key # Options: api-key, none (for demo only)
apiKey: "" # REQUIRED in production - set via --set or values override
# Logging configuration
logging:
level: info # debug, info, warn, error
format: json # json or text
# SMTP configuration for email notifications (optional)
smtp:
enabled: false
host: ""
port: 587
username: ""
password: ""
fromAddress: ""
useTLS: true
# Certificate digest digest (periodic email summary)
digest:
enabled: false
interval: "24h"
recipients: []
# Example:
# - admin@example.com
# - ops@example.com
# Enrollment over Secure Transport (EST) configuration
est:
enabled: false
issuerID: "iss-local"
profileID: ""
# Rate limiting configuration
rateLimiting:
rps: 100 # Requests per second
burst: 200 # Burst capacity
# Network scanning configuration
networkScan:
enabled: false
interval: "6h"
# Certificate key generation mode
keygen:
mode: agent # Options: agent (production), server (demo with warning)
# CORS configuration
cors:
origins: "" # Comma-separated list, empty means deny all cross-origin requests
# Issuer connectors configuration
issuer:
local:
enabled: true
# For sub-CA mode, provide these paths:
# caCertPath: /path/to/ca.crt
# caKeyPath: /path/to/ca.key
acme:
enabled: false
directoryURL: ""
email: ""
challengeType: "http-01" # Options: http-01, dns-01, dns-persist-01
# DNS configuration (for dns-01 or dns-persist-01)
# dnsPresentScript: /path/to/dns-present.sh
# dnsCleanupScript: /path/to/dns-cleanup.sh
# dnsPropagationWait: "30s"
# dnsPersistIssuerDomain: "validation.example.com"
# EAB configuration (for ZeroSSL, Google Trust Services, etc.)
# eabKid: ""
# eabHmac: ""
stepca:
enabled: false
# rootCAPath: /path/to/root_ca.crt
# intermediateCAPath: /path/to/intermediate_ca.crt
# provisionerName: ""
# provisionerPassword: ""
openssl:
enabled: false
# signScript: /path/to/sign.sh
# revokeScript: /path/to/revoke.sh
# crlScript: /path/to/crl.sh
# timeoutSeconds: 30
# Notifier connectors configuration
notifiers:
slack:
enabled: false
# webhookUrl: ""
# channel: ""
# username: ""
# iconEmoji: ""
teams:
enabled: false
# webhookUrl: ""
pagerduty:
enabled: false
# routingKey: ""
# severity: warning
opsgenie:
enabled: false
# apiKey: ""
# priority: P3
# Additional environment variables
# Will be passed as-is to the server container
env: {}
# Example:
# CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL: "1h"
# CERTCTL_DATABASE_MAX_CONNS: "25"
# Additional volume mounts for custom configurations
# volumeMounts: []
# - name: ca-cert
# mountPath: /etc/ssl/certs/ca.crt
# subPath: ca.crt
# Additional volumes
# volumes: []
# - name: ca-cert
# secret:
# secretName: ca-cert
# ==============================================================================
# PostgreSQL Configuration
# ==============================================================================
postgresql:
# Enable/disable PostgreSQL (set to false if using external database)
enabled: true
# Image configuration
image:
repository: postgres
tag: "16-alpine"
pullPolicy: IfNotPresent
# Authentication
auth:
database: certctl
username: certctl
password: "" # REQUIRED - set via --set or values override
# Storage configuration
storage:
size: 10Gi
storageClass: "" # Uses default StorageClass if empty
# deleteOnTermination: false # Keep data on Helm uninstall
# Resource requests and limits
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
# Pod security context
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
# Liveness and readiness probes
livenessProbe:
exec:
command:
- /bin/sh
- -c
- pg_isready -U certctl -d certctl
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command:
- /bin/sh
- -c
- pg_isready -U certctl -d certctl
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
# Service configuration
service:
type: ClusterIP
port: 5432
# PostgreSQL-specific settings
postgresqlConfig: {}
# Example:
# max_connections: "200"
# shared_buffers: "256MB"
# ==============================================================================
# Certctl Agent Configuration
# ==============================================================================
agent:
# Enable/disable agent deployment
enabled: true
# Deployment strategy: DaemonSet (recommended) or Deployment
kind: DaemonSet # Options: DaemonSet, Deployment
# Image configuration
image:
repository: ghcr.io/shankar0123/certctl-agent
tag: "" # defaults to Chart.appVersion
pullPolicy: IfNotPresent
# Number of replicas (for Deployment kind; ignored for DaemonSet)
replicas: 1
# Resource requests and limits
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
# Pod security context
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
# Agent name (can be overridden per pod via StatefulSet ordinals)
name: "" # If empty, uses release name
# Key storage directory
keyDir: /var/lib/certctl/keys
# Certificate discovery directories (comma-separated)
discoveryDirs: ""
# Example: "/etc/ssl/certs,/etc/pki/tls"
# Node selector for agent pods (for DaemonSet)
nodeSelector: {}
# Example:
# node-role.kubernetes.io/worker: "true"
# Tolerations for agent pods
tolerations: []
# Example:
# - key: node-role
# operator: Equal
# value: worker
# effect: NoSchedule
# Affinity rules
affinity: {}
# Additional environment variables
env: {}
# ==============================================================================
# Ingress Configuration
# ==============================================================================
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: certctl.local
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: certctl-tls
# hosts:
# - certctl.local
# ==============================================================================
# Service Account Configuration
# ==============================================================================
serviceAccount:
create: true
annotations: {}
name: "" # defaults to release name if empty
# ==============================================================================
# RBAC Configuration
# ==============================================================================
rbac:
create: true
# ==============================================================================
# Pod Disruption Budget (for HA deployments)
# ==============================================================================
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
# ==============================================================================
# Monitoring Configuration
# ==============================================================================
monitoring:
enabled: false
# Prometheus ServiceMonitor
serviceMonitor:
enabled: false
interval: 30s
scrapeTimeout: 10s
# labels: {}
# selector: {}
# ==============================================================================
# Advanced Configuration
# ==============================================================================
# Node affinity for server pods
nodeAffinity: {}
# Pod affinity for server pods
podAffinity: {}
# Pod anti-affinity for server pods (for HA)
podAntiAffinity: {}
# Example:
# podAntiAffinity:
# preferredDuringSchedulingIgnoredDuringExecution:
# - weight: 100
# podAffinityTerm:
# labelSelector:
# matchExpressions:
# - key: app.kubernetes.io/name
# operator: In
# values:
# - certctl
# topologyKey: kubernetes.io/hostname
# Custom labels for all resources
customLabels: {}
# Custom annotations for all resources
customAnnotations: {}
@@ -0,0 +1,77 @@
# Certctl with ACME DNS-01 Challenge (Let's Encrypt)
# Enables automatic certificate issuance from Let's Encrypt
# using DNS-01 verification (wildcard-capable)
server:
auth:
type: api-key
apiKey: "CHANGE_ME"
issuer:
local:
enabled: true
acme:
enabled: true
directoryURL: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
challengeType: dns-01
dnsPresentScript: /scripts/dns-present.sh
dnsCleanupScript: /scripts/dns-cleanup.sh
dnsPropagationWait: 30s
# For DNS-PERSIST-01 (standing validation record, no per-renewal updates):
# challengeType: dns-persist-01
# dnsPersistIssuerDomain: validation.example.com
# Mount DNS scripts as ConfigMap
volumes:
- name: dns-scripts
configMap:
name: dns-scripts
defaultMode: 0755
volumeMounts:
- name: dns-scripts
mountPath: /scripts
readOnly: true
postgresql:
enabled: true
storage:
size: 20Gi
agent:
enabled: true
kind: DaemonSet
ingress:
enabled: true
className: nginx
hosts:
- host: certctl.example.com
paths:
- path: /
pathType: Prefix
---
# You'll need to create the DNS scripts ConfigMap separately:
#
# kubectl create configmap dns-scripts \
# --from-file=dns-present.sh=./scripts/dns-present.sh \
# --from-file=dns-cleanup.sh=./scripts/dns-cleanup.sh
#
# Example dns-present.sh (Cloudflare):
# #!/bin/bash
# DOMAIN=$1
# TOKEN=$2
#
# curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" \
# -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
# -d "{\"type\":\"TXT\",\"name\":\"_acme-challenge.${DOMAIN}\",\"content\":\"${TOKEN}\"}"
#
# Example dns-cleanup.sh (Cloudflare):
# #!/bin/bash
# DOMAIN=$1
#
# curl -X DELETE "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
# -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}"
+99
View File
@@ -0,0 +1,99 @@
# Certctl Development Configuration
# Lightweight setup for development and testing
# - Single server replica
# - Small PostgreSQL storage
# - Minimal resource limits
# - No ingress or monitoring
# - Demo auth mode (no API key required)
server:
replicas: 1
image:
repository: ghcr.io/shankar0123/certctl
pullPolicy: IfNotPresent # Use latest tag
port: 8443
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
auth:
type: none # Demo mode - no authentication
logging:
level: debug
format: json
service:
type: LoadBalancer # Easy external access for dev
issuer:
local:
enabled: true
rateLimiting:
rps: 100
burst: 200
postgresql:
enabled: true
image:
repository: postgres
tag: "16-alpine"
pullPolicy: IfNotPresent
auth:
database: certctl
username: certctl
password: "dev-password-change-me"
storage:
size: 5Gi
storageClass: "" # Use default storage class
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
agent:
enabled: true
kind: Deployment
replicas: 1
image:
repository: ghcr.io/shankar0123/certctl-agent
pullPolicy: IfNotPresent
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
ingress:
enabled: false
serviceAccount:
create: true
rbac:
create: true
monitoring:
enabled: false
customLabels:
environment: development
@@ -0,0 +1,50 @@
# Certctl with External PostgreSQL Database
# Use this when PostgreSQL is managed externally:
# - AWS RDS
# - Cloud SQL (Google Cloud)
# - Azure Database for PostgreSQL
# - Self-managed PostgreSQL server
server:
replicas: 2
auth:
type: api-key
apiKey: "CHANGE_ME"
issuer:
local:
enabled: true
# Pass external database URL via environment variable
env:
CERTCTL_DATABASE_URL: "postgres://certctl:CHANGE_ME@postgres.example.com:5432/certctl?sslmode=require"
# Disable internal PostgreSQL
postgresql:
enabled: false
agent:
enabled: true
kind: DaemonSet
ingress:
enabled: true
className: nginx
hosts:
- host: certctl.example.com
paths:
- path: /
pathType: Prefix
# For AWS RDS with IAM authentication:
# env:
# CERTCTL_DATABASE_URL: "postgres://certctl:CHANGE_ME@mydb.123456789.us-east-1.rds.amazonaws.com:5432/certctl?sslmode=require"
# For Google Cloud SQL:
# env:
# CERTCTL_DATABASE_URL: "postgres://certctl:CHANGE_ME@/certctl?host=/cloudsql/PROJECT:REGION:INSTANCE&sslmode=require"
# For Azure Database:
# env:
# CERTCTL_DATABASE_URL: "postgres://certctl@servername:CHANGE_ME@servername.postgres.database.azure.com:5432/certctl?sslmode=require"
+159
View File
@@ -0,0 +1,159 @@
# Certctl Production HA Configuration
# High availability deployment with:
# - 3 server replicas with pod anti-affinity
# - Large PostgreSQL storage
# - Resource limits for production
# - Prometheus monitoring
# - Network policies enforcement
namespace: certctl
server:
replicas: 3
image:
repository: ghcr.io/shankar0123/certctl
tag: "2.1.0"
pullPolicy: IfNotPresent
port: 8443
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
auth:
type: api-key
apiKey: "CHANGE_ME_IN_PRODUCTION" # Use --set or sealed-secrets
logging:
level: info
format: json
service:
type: ClusterIP
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8443"
prometheus.io/path: "/api/v1/metrics/prometheus"
issuer:
local:
enabled: true
acme:
enabled: true
directoryURL: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
challengeType: dns-01
rateLimiting:
rps: 500
burst: 1000
postgresql:
enabled: true
image:
repository: postgres
tag: "16-alpine"
pullPolicy: IfNotPresent
auth:
database: certctl
username: certctl
password: "CHANGE_ME_IN_PRODUCTION" # Use --set or sealed-secrets
storage:
size: 100Gi
storageClass: "fast-ssd" # Use your high-performance storage class
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 2000m
memory: 2Gi
agent:
enabled: true
kind: DaemonSet
image:
repository: ghcr.io/shankar0123/certctl-agent
tag: "2.1.0"
pullPolicy: IfNotPresent
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
discoveryDirs: "/etc/ssl/certs,/etc/pki/tls,/etc/ssl"
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
hosts:
- host: certctl.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: certctl-tls
hosts:
- certctl.example.com
serviceAccount:
create: true
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT:role/certctl-role # For IRSA on AWS
rbac:
create: true
podDisruptionBudget:
enabled: true
minAvailable: 2
monitoring:
enabled: true
serviceMonitor:
enabled: true
interval: 30s
scrapeTimeout: 10s
# Pod anti-affinity for HA
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- certctl
- key: app.kubernetes.io/component
operator: In
values:
- server
topologyKey: kubernetes.io/hostname
customLabels:
environment: production
team: platform
cost-center: ops
customAnnotations:
slack-alerts: "#ops"
backup-policy: daily
+29 -5
View File
@@ -450,7 +450,7 @@ Short-lived certificates (those with profile TTL < 1 hour) return "good" from OC
### 4. Automatic Renewal
The control plane runs a scheduler with six background loops:
The control plane runs a scheduler with seven background loops:
```mermaid
flowchart LR
@@ -461,6 +461,7 @@ flowchart LR
N["Notification Processor\n⏱ every 1m"]
SL["Short-Lived Expiry\n⏱ every 30s"]
NS["Network Scanner\n⏱ every 6h"]
DG["Certificate Digest\n⏱ every 24h"]
end
R -->|"Find expiring certs\nCreate renewal jobs"| DB[("PostgreSQL")]
@@ -469,6 +470,7 @@ flowchart LR
N -->|"Send pending notifications\nEmail / Webhook / Slack"| DB
SL -->|"Expire short-lived certs\nMark as Expired"| DB
NS -->|"Probe TLS endpoints\nStore discovered certs"| DB
DG -->|"Generate & send HTML digest\nEmail to recipients"| DB
```
| Loop | Interval | Timeout | Purpose |
@@ -479,8 +481,9 @@ flowchart LR
| Notification processor | 1 minute | 1 minute | Sends pending notifications via configured channels |
| Short-lived expiry | 30 seconds | 30 seconds | Marks expired short-lived certificates (profile TTL < 1 hour) |
| Network scanner | 6 hours | 30 minutes | Probes TLS endpoints on configured CIDR ranges, stores discovered certs (M21, opt-in via `CERTCTL_NETWORK_SCAN_ENABLED`). CIDR size validated at API level — max /20 (4096 IPs) per range. |
| Certificate digest | 24 hours | 5 minutes | Generates HTML email with certificate stats, expiration timeline, job health, agent count. Does NOT run on startup — waits for first scheduled tick. Configurable interval and recipients via `CERTCTL_DIGEST_INTERVAL` and `CERTCTL_DIGEST_RECIPIENTS`. Falls back to certificate owner emails if no explicit recipients configured. |
Each loop uses `sync/atomic.Bool` idempotency guards to prevent concurrent tick execution — if a loop iteration is still running when the next tick fires, the tick is skipped with a warning log. All loops (including short-lived expiry check) run immediately on startup before entering their ticker interval, ensuring no gap between scheduler start and first execution. Graceful shutdown uses `sync.WaitGroup` with `WaitForCompletion()` to drain all in-flight work before process exit.
Each loop uses `sync/atomic.Bool` idempotency guards to prevent concurrent tick execution — if a loop iteration is still running when the next tick fires, the tick is skipped with a warning log. All loops (including short-lived expiry check) run immediately on startup before entering their ticker interval, ensuring no gap between scheduler start and first execution. The certificate digest loop is the exception — it does NOT run on startup, only on scheduled ticks. Graceful shutdown uses `sync.WaitGroup` with `WaitForCompletion()` to drain all in-flight work before process exit.
Each operation has a context timeout to prevent indefinite hangs if external services become unresponsive.
@@ -567,7 +570,11 @@ type Connector interface {
}
```
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required. The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
**ACME Renewal Information (ARI, RFC 9702):** The ACME connector supports CA-directed renewal timing via the `GetRenewalInfo()` method. Instead of using fixed thresholds (e.g., renew 30 days before expiry), the CA tells certctl when to renew by providing a `suggestedWindow` with start and end times. This is useful for distributing renewal load during maintenance windows and coordinating mass-revocation scenarios. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. Cert ID is computed as `base64url(SHA-256(DER cert))` per RFC 9702. If the CA doesn't support ARI (404 from the ARI endpoint), certctl automatically falls back to threshold-based renewal — no operator intervention required. Errors from the CA are logged as warnings.
The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
### Target Connector
@@ -763,7 +770,7 @@ All endpoints are under `/api/v1/` and follow consistent patterns:
Resources: certificates, issuers, targets, agents, jobs, policies, profiles, teams, owners, agent-groups, audit, notifications, discovered-certificates, discovery-scans, network-scan-targets, stats, metrics.
The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml` with 97 endpoints across 20 resource domains (95 under `/api/v1/` + `/.well-known/est/` plus `/health` and `/ready`; includes auth, 7 discovery endpoints from M18b, 6 network scan endpoints from M21, Prometheus metrics from M22, and 4 EST enrollment endpoints from M23), all request/response schemas, and pagination conventions. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation.
The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml` with 99 endpoints across 23 resource domains (97 under `/api/v1/` + `/.well-known/est/` plus `/health` and `/ready`; includes auth, 7 discovery endpoints from M18b, 6 network scan endpoints from M21, Prometheus metrics from M22, 4 EST enrollment endpoints from M23, 2 digest endpoints from M29), all request/response schemas, and pagination conventions. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation.
Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST /api/v1/jobs/{id}/approve`, `POST /api/v1/jobs/{id}/reject`.
@@ -835,7 +842,9 @@ flowchart TB
**Credentials & Configuration:**
Database and API credentials are managed via environment variables defined in a `.env` file. Copy `deploy/.env.example` to `deploy/.env` for local development and customize credentials for production. The agent key directory (`CERTCTL_KEY_DIR`) is persisted as a named Docker volume (`agent_keys`) at `/var/lib/certctl/keys` for reliable key storage across container restarts.
### Production (Kubernetes)
### Production (Kubernetes with Helm)
A production-ready Helm chart is available under `deploy/helm/certctl/` with full support for multi-replica deployments, persistent PostgreSQL, agent DaemonSet, optional Ingress, and security best practices.
```mermaid
flowchart TB
@@ -861,6 +870,21 @@ flowchart TB
DS --> DEP
```
**Helm Installation:**
```bash
# Add the chart (if published) or install from local directory
helm install certctl deploy/helm/certctl/ \
--set server.auth.apiKey="your-secure-key" \
--set postgresql.auth.password="your-db-password" \
--set ingress.enabled=true \
--set ingress.hosts[0].host="certctl.example.com"
```
The Helm chart includes: server Deployment with configurable replicas, liveness/readiness probes, security context (non-root, read-only rootfs), PostgreSQL StatefulSet with persistent volumes, optional Ingress with TLS, ServiceAccount with configurable RBAC, and agent DaemonSet running one agent per node. All certctl configuration options are exposed in `values.yaml` — issuers, targets, notifiers, scheduler intervals, discovery settings, and SMTP for digest emails.
See `deploy/helm/certctl/values.yaml` for the full configuration reference and `deploy/helm/certctl/Chart.yaml` for version and appVersion details.
For production, you would also add an ingress controller, TLS termination for the certctl API itself, and external PostgreSQL (RDS, Cloud SQL, etc.).
## Discovery Data Flow (M18b + M21)
+13
View File
@@ -183,6 +183,19 @@ Profiles are managed via the API (`/api/v1/profiles`) and the GUI, and can be as
For policies with `auto_renew` disabled, renewal jobs enter an **AwaitingApproval** state instead of processing immediately. An operator must explicitly approve or reject the renewal via the API or GUI. Approved jobs transition to Pending and are picked up by the scheduler. Rejected jobs are cancelled with an optional reason. This is useful for high-value certificates where you want human oversight before renewal.
### Renewal Timing: Thresholds vs. ARI (RFC 9702)
**Traditional approach (thresholds):** By default, certctl uses static renewal thresholds — renew a certificate at a fixed number of days before expiry (default: 30 days). This simple, predictable model works for most use cases: it avoids unnecessary renewals near expiry and gives you a predictable window to catch failures.
**Advanced approach (ACME ARI):** Some Certificate Authorities support ACME Renewal Information (RFC 9702), which allows the CA to tell certctl the optimal time to renew. Instead of guessing "renew 30 days before expiry," the CA responds with a precise `suggestedWindow` containing start and end times. This is useful when:
- The CA is performing maintenance and wants to batch renewals in a specific window
- The CA is coordinating a mass revocation (e.g., due to a compromise) and needs to control renewal timing
- You want to avoid thundering herd renewal spikes by accepting the CA's suggested timing
**How it works:** Enable with `CERTCTL_ACME_ARI_ENABLED=true` on your ACME issuer. When a certificate approaches expiry, certctl queries the ARI endpoint with the certificate's DER encoding. The CA responds with a suggested renewal window. If the current time is within the window or past the start time, certctl renews immediately. Otherwise, it waits until the window opens.
**Graceful degradation:** If your CA doesn't support ARI (returns 404 from the ARI endpoint), certctl automatically falls back to the traditional threshold-based renewal. No configuration change needed — the fallback is transparent. Errors from the CA are logged as warnings and don't block the renewal process.
### Certificate Revocation
When a private key is compromised, a certificate is superseded, or a service is decommissioned, you need to revoke the certificate immediately — not wait for it to expire. Revocation tells clients "stop trusting this certificate right now."
+61 -1
View File
@@ -171,6 +171,8 @@ The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x
**DNS-PERSIST-01 (standing record):** Creates a one-time persistent TXT record at `_validation-persist.<domain>` containing the CA's issuer domain and your ACME account URI. Once set, this record authorizes unlimited future certificate issuances without per-renewal DNS updates. Based on [draft-ietf-acme-dns-persist](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/) and CA/Browser Forum ballot SC-088v3. If the CA doesn't offer dns-persist-01 yet, the connector falls back to dns-01 automatically.
**ACME Renewal Information (ARI, RFC 9702):** Instead of using fixed renewal thresholds (e.g., renew 30 days before expiry), certctl can ask the CA when it should renew. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. The ARI protocol lets the CA specify a `suggestedWindow` (start and end times) for when you should renew — useful for distributing load during maintenance windows or coordinating mass revocation scenarios. Cert ID is computed as `base64url(SHA-256(DER cert))`. If the CA doesn't support ARI (404 response), certctl automatically falls back to threshold-based renewal with no operator intervention required.
HTTP-01 configuration:
```json
{
@@ -622,11 +624,69 @@ type Connector interface {
Built-in notifiers: **Email** (SMTP), **Webhook** (HTTP POST), **Slack** (incoming webhook), **Microsoft Teams** (MessageCard webhook), **PagerDuty** (Events API v2), and **OpsGenie** (Alert API v2).
### Email (SMTP) Notifier
The Email notifier sends transactional alerts and scheduled digests via SMTP. It bridges the connector-layer SMTP connector to the service-layer `Notifier` interface via the `NotifierAdapter`. Supports both plain text and HTML emails.
Configuration:
| Variable | Default | Description |
|----------|---------|-------------|
| `CERTCTL_SMTP_HOST` | — | SMTP server hostname (required to enable) |
| `CERTCTL_SMTP_PORT` | 587 | SMTP port (TLS) |
| `CERTCTL_SMTP_USERNAME` | — | SMTP authentication username (optional) |
| `CERTCTL_SMTP_PASSWORD` | — | SMTP authentication password (optional) |
| `CERTCTL_SMTP_FROM_ADDRESS` | — | Email from address (required) |
| `CERTCTL_SMTP_USE_TLS` | true | Enable TLS encryption |
Example:
```bash
export CERTCTL_SMTP_HOST=smtp.gmail.com
export CERTCTL_SMTP_PORT=587
export CERTCTL_SMTP_USERNAME=admin@example.com
export CERTCTL_SMTP_PASSWORD=app-password-123
export CERTCTL_SMTP_FROM_ADDRESS=certctl@example.com
```
### Scheduled Certificate Digest
The `DigestService` generates aggregated certificate digest emails and sends them on a configurable schedule. This is useful for periodic briefings on certificate inventory health — expiring certs, status summary, active agents, job trends.
The digest HTML template includes:
- Total certificates, expiring soon, expired, active agents (stats grid)
- Jobs completed/failed summary (30 days)
- Expiring certificates table (color-coded by urgency: 7d, 14d, 30d)
- Auto-refresh and responsive email layout
**Scheduler Integration:** The 7th scheduler loop runs on configurable interval (default 24 hours). It does NOT run on startup — waits for first scheduled tick. Operation timeout is 5 minutes. Each loop execution is guarded by `sync/atomic.Bool` idempotency.
Configuration:
| Variable | Default | Description |
|----------|---------|-------------|
| `CERTCTL_DIGEST_ENABLED` | false | Enable scheduled digest emails |
| `CERTCTL_DIGEST_INTERVAL` | 24h | How often to send digest (any duration, e.g. 12h, 7d) |
| `CERTCTL_DIGEST_RECIPIENTS` | — | Comma-separated email addresses. Falls back to certificate owner emails if empty |
API Endpoints:
- **`GET /api/v1/digest/preview`** — Render digest HTML for preview (no email sent)
- **`POST /api/v1/digest/send`** — Trigger digest send immediately (outside of schedule)
Example:
```bash
# Preview digest
curl http://localhost:8443/api/v1/digest/preview | jq '.html'
# Send digest immediately
curl -X POST http://localhost:8443/api/v1/digest/send
```
Each notifier is enabled by its configuration env var:
| Notifier | Env Var | Description |
|----------|---------|-------------|
| Email | `CERTCTL_EMAIL_SMTP_HOST`, `CERTCTL_EMAIL_SMTP_PORT`, `CERTCTL_EMAIL_FROM` | SMTP email delivery. Optional: `CERTCTL_EMAIL_SMTP_USERNAME`, `CERTCTL_EMAIL_SMTP_PASSWORD` |
| Email | `CERTCTL_SMTP_HOST` | SMTP email delivery. See Email Notifier section above |
| Webhook | `CERTCTL_WEBHOOK_URL` | HTTP POST to any endpoint. Optional: `CERTCTL_WEBHOOK_SECRET` for HMAC signing |
| Slack | `CERTCTL_SLACK_WEBHOOK_URL` | Incoming webhook URL. Optional: `CERTCTL_SLACK_CHANNEL`, `CERTCTL_SLACK_USERNAME` |
| Teams | `CERTCTL_TEAMS_WEBHOOK_URL` | Incoming webhook URL (MessageCard format) |
+144 -1
View File
@@ -7,7 +7,7 @@ Complete reference of all features shipped in the V2 release (as of March 2026).
## API Surface
### Overview
- **97 endpoints** across 21 resource domains under `/api/v1/` + `/.well-known/est/`
- **99 endpoints** across 23 resource domains under `/api/v1/` + `/.well-known/est/`
- REST API with HTTP semantics (GET, POST, PUT, DELETE)
- All endpoints require authentication by default (configurable)
- OpenAPI 3.1 spec with full schema documentation
@@ -96,6 +96,7 @@ curl -H "$AUTH" "$SERVER/api/v1/certificates?expires_before=2026-04-24T00:00:00Z
| **Stats** | 5 | Dashboard summary, certificates by status, expiration timeline, job trends, issuance rate |
| **Metrics** | 2 | JSON metrics (gauges, counters, uptime), Prometheus exposition format |
| **Verification** | 2 | Submit verification result, get verification status |
| **Digest** | 2 | Preview HTML digest, send digest immediately |
| **EST (RFC 7030)** | 4 | CA certs (PKCS#7), simple enrollment, re-enrollment, CSR attributes |
| **Health** | 4 | Health check, readiness check, auth info, auth check |
@@ -513,6 +514,148 @@ export CERTCTL_PAGERDUTY_SEVERITY="critical"
---
## ACME Renewal Information (ARI, RFC 9702)
Instead of using fixed renewal thresholds (renew 30 days before expiry), ACME ARI lets the CA tell certctl exactly when to renew. This is useful for distributing renewal load across maintenance windows and coordinating mass-revocation scenarios.
**How it works:**
```bash
# Enable ARI on your ACME issuer
export CERTCTL_ACME_ARI_ENABLED=true
# Certificates now query the ARI endpoint for suggested renewal windows
# If the CA doesn't support ARI (404), certctl falls back to threshold-based renewal
```
| Field | Details |
|-------|---------|
| **Protocol** | ACME Renewal Information (RFC 9702) |
| **Cert ID Computation** | base64url(SHA-256(DER cert)) |
| **Suggested Window** | Start and end times provided by CA |
| **Renewal Timing** — If current time is after window start, renew immediately. Otherwise, wait until start time. |
| **Fallback** | 404 from ARI endpoint triggers automatic fallback to threshold-based renewal |
| **Configuration** | `CERTCTL_ACME_ARI_ENABLED=true` on ACME issuer config |
| **Supported CAs** | Let's Encrypt (v2.1.0+), Sectigo, others gradually adopting |
**Benefits:**
- **Load Distribution** — CA specifies renewal window to avoid thundering herd spikes
- **Coordination** — Support for mass revocation scenarios where CA controls timing
- **No Over-Renewal** — Avoid unnecessary early renewals that waste your CA's capacity
---
## Scheduled Certificate Digest Emails
Scheduled HTML digest emails with certificate stats, expiration timeline, job health, and agent fleet overview. Useful for daily ops briefings and compliance reporting.
```bash
# Configure SMTP
export CERTCTL_SMTP_HOST=smtp.example.com
export CERTCTL_SMTP_PORT=587
export CERTCTL_SMTP_USERNAME=admin@example.com
export CERTCTL_SMTP_PASSWORD=your-app-password
export CERTCTL_SMTP_FROM_ADDRESS=certctl@example.com
# Enable digest
export CERTCTL_DIGEST_ENABLED=true
export CERTCTL_DIGEST_INTERVAL=24h
export CERTCTL_DIGEST_RECIPIENTS=ops@example.com,security@example.com
```
| Feature | Details |
|---------|---------|
| **Scheduler Loop** | 7th background loop, default 24-hour interval (configurable: 12h, 7d, etc.) |
| **Startup Behavior** | Does NOT run on startup; waits for first scheduled tick |
| **Operation Timeout** | 5 minutes per digest generation + send |
| **Idempotency**`sync/atomic.Bool` guard prevents concurrent digest executions |
| **HTML Template** | Responsive email with stats grid (total, expiring, expired, agents), jobs summary (30-day), expiring certs table with color-coded urgency (7/14/30 days) |
| **Recipients** | Comma-separated email addresses. Falls back to certificate owner emails if none configured. |
| **API Endpoints**`GET /api/v1/digest/preview` (HTML preview), `POST /api/v1/digest/send` (trigger immediately) |
| **Configuration**`CERTCTL_DIGEST_ENABLED`, `CERTCTL_DIGEST_INTERVAL` (default 24h), `CERTCTL_DIGEST_RECIPIENTS` |
**Digest Contents:**
- **Certificate Stats** — Total, active, expiring soon, expired, revoked
- **Job Health** — Completed, failed (last 30 days)
- **Agent Fleet** — Total agents online, offline, version distribution
- **Expiring Certificates** — Table with CN, SANs, days remaining, owner, status badges
**Use Cases:**
- Daily ops briefing for certificate inventory health
- Compliance reporting (audit trail + digest archive)
- Stakeholder visibility (automated newsletter)
---
## Helm Chart for Kubernetes
Production-ready Helm chart for Kubernetes deployments with secure defaults and comprehensive configurability.
### Chart Components
| Component | Details |
|-----------|---------|
| **Server Deployment** | Configurable replicas (default 2), liveness/readiness probes, security context (non-root, read-only rootfs), resource limits, graceful shutdown |
| **PostgreSQL StatefulSet** | Primary + replica, persistent volumes with configurable storage class/size (default 10Gi), automatic backup (via init container or sidecarsynchronous |
| **Agent DaemonSet** | One agent per infrastructure node, key storage volume (agent_keys), server discovery via internal DNS |
| **ConfigMap** | Issuer, target, and scheduler configuration; all certctl env vars exposed |
| **Secret** — API key, database password, SMTP credentials (base64-encoded) |
| **Ingress** — Optional with TLS, configurable hostname and certificate (via cert-manager or manual) |
| **ServiceAccount** — RBAC with configurable annotations for Kubernetes audit logging |
### Installation
```bash
# Install with custom values
helm install certctl deploy/helm/certctl/ \
--namespace certctl --create-namespace \
--set server.auth.apiKey="your-secure-key" \
--set postgresql.auth.password="your-db-password" \
--set ingress.enabled=true \
--set ingress.hosts[0].host="certctl.example.com" \
--set ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod"
```
### Key Values
| Value | Default | Description |
|-------|---------|-------------|
| `server.replicaCount` | 2 | Number of server replicas |
| `server.auth.apiKey` | — | (required) API key for authentication |
| `postgresql.auth.password` | — | (required) PostgreSQL password |
| `postgresql.storage.size` | 10Gi | Database volume size |
| `ingress.enabled` | false | Enable Ingress for public access |
| `ingress.hosts[0].host` | certctl.example.com | Primary hostname |
| `ingress.tls.enabled` | true | TLS on Ingress (requires cert-manager) |
| `agent.enabled` | true | Deploy agent DaemonSet |
| `smtp.enabled` | false | Enable SMTP for digest emails |
| `smtp.host` | — | SMTP server hostname |
### Security Defaults
- **Non-root containers** — Server and agent run as unprivileged user
- **Read-only filesystem** — Root filesystem mounted read-only (except /tmp)
- **Network policies** — Optional KubernetesNetworkPolicy to restrict traffic
- **Secrets** — API keys and passwords stored in K8s Secrets, never in ConfigMaps or environment defaults
- **RBAC** — ServiceAccount with minimal required permissions
### Upgrade Path
```bash
# Upgrade to a new certctl release
helm upgrade certctl deploy/helm/certctl/ \
--namespace certctl \
-f my-values.yaml
# Rollback if needed
helm rollback certctl [REVISION]
```
---
## Agent Fleet
Agents are lightweight Go binaries deployed on your servers that handle the last mile — generating private keys locally, submitting CSRs, and deploying signed certificates to web servers. The control plane never touches private keys or initiates outbound connections, keeping your security perimeter intact.
+47
View File
@@ -43,6 +43,8 @@ On Linux, follow the official Docker install guide for your distribution.
## Start Everything
### Docker Compose (Quick Start)
```bash
git clone https://github.com/shankar0123/certctl.git
cd certctl
@@ -58,6 +60,22 @@ cp deploy/.env.example deploy/.env
docker compose -f deploy/docker-compose.yml up -d --build
```
### Kubernetes with Helm
For production deployments on Kubernetes, use the Helm chart:
```bash
helm install certctl deploy/helm/certctl/ \
--create-namespace --namespace certctl \
--set server.auth.apiKey="your-secure-api-key" \
--set postgresql.auth.password="your-db-password" \
--set ingress.enabled=true \
--set ingress.hosts[0].host="certctl.example.com" \
--set ingress.hosts[0].tls=true
```
The chart includes: server Deployment (with configurable replicas, health probes, security context), PostgreSQL StatefulSet with persistent volumes, agent DaemonSet (one agent per infrastructure node), optional Ingress with TLS, and ServiceAccount with RBAC. All certctl configuration options are exposed in `values.yaml` — customize issuer settings, target connectors, scheduler intervals, and notifier credentials there.
Wait about 30 seconds for PostgreSQL to initialize, then verify:
```bash
@@ -346,6 +364,35 @@ export CERTCTL_API_KEY="test-key-123"
./certctl-cli status # Health + stats
```
## Scheduled Certificate Digest Emails
Enable automatic HTML digest emails with certificate stats, expiration timeline, and job health:
```bash
# Set SMTP configuration
export CERTCTL_SMTP_HOST=smtp.gmail.com
export CERTCTL_SMTP_PORT=587
export CERTCTL_SMTP_USERNAME=admin@example.com
export CERTCTL_SMTP_PASSWORD=your-app-password
export CERTCTL_SMTP_FROM_ADDRESS=certctl@example.com
export CERTCTL_SMTP_USE_TLS=true
# Enable digest and set recipients
export CERTCTL_DIGEST_ENABLED=true
export CERTCTL_DIGEST_INTERVAL=24h
export CERTCTL_DIGEST_RECIPIENTS=ops@example.com,security@example.com
```
Preview the digest HTML before enabling scheduled delivery:
```bash
curl http://localhost:8443/api/v1/digest/preview | jq '.html' | grep -o '<html>' # Shows HTML is ready
# Trigger a digest send immediately (outside of schedule)
curl -X POST http://localhost:8443/api/v1/digest/send
```
If no recipients are configured (`CERTCTL_DIGEST_RECIPIENTS` empty), the digest falls back to certificate owner emails. Digests include total certificates, expiring soon, expired, active agents, completed/failed jobs (30-day summary), and a table of expiring certs color-coded by urgency (7/14/30 days).
## MCP Server (AI Integration)
```bash
+711 -3
View File
@@ -33,6 +33,12 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
- [Part 26: EST Server (RFC 7030)](#part-26-est-server-rfc-7030)
- [Part 27: Post-Deployment TLS Verification](#part-27-post-deployment-tls-verification)
- [Part 28: Traefik & Caddy Target Connectors](#part-28-traefik--caddy-target-connectors)
- [Part 29: Certificate Export (PEM & PKCS#12)](#part-29-certificate-export-pem--pkcs12)
- [Part 30: S/MIME & EKU Support](#part-30-smime--eku-support)
- [Part 31: OCSP Responder & DER CRL](#part-31-ocsp-responder--der-crl)
- [Part 32: Request Body Size Limits](#part-32-request-body-size-limits)
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
- [Release Sign-Off](#release-sign-off)
---
@@ -1985,8 +1991,8 @@ curl -s -w "\nHTTP %{http_code}\n" -X POST -H "$AUTH" -H "$CT" \
curl -s -H "$AUTH" "$SERVER/api/v1/profiles" | jq '{total, ids: [.items[].id]}'
```
**Expected:** `total` = 4 (seed profiles).
**PASS if** total = 4. **FAIL** otherwise.
**Expected:** `total` = 5 (seed profiles: prof-standard-tls, prof-internal-mtls, prof-short-lived, prof-wildcard, prof-smime).
**PASS if** total = 5. **FAIL** otherwise.
---
@@ -4367,9 +4373,705 @@ go test ./internal/connector/target/caddy/... -v
---
## Part 29: Certificate Export (PEM & PKCS#12)
**What:** certctl lets operators export managed certificates in two formats — PEM (JSON or file download) and PKCS#12 (.p12 bundle). Private keys are **never** included in exports since they live exclusively on agents. This section verifies both export paths, the audit trail they produce, and the GUI integration.
**Why:** Certificate export is a daily operational task — feeding certs into load balancers that lack agent support, importing into Java trust stores, or handing off to external teams. If export silently produces malformed output or fails to audit, operators lose trust in the platform.
### 29.1: Export PEM (JSON Response)
**What:** `GET /api/v1/certificates/{id}/export/pem` returns a JSON object with the leaf certificate PEM, the CA chain PEM, and the full concatenated PEM. This is the default response format when no `?download=true` query parameter is present.
**Why:** The JSON format lets automation scripts programmatically extract the leaf cert separately from the chain — a common need for split-file deployments (Apache, custom TLS termination).
```bash
# Use an existing certificate ID from seed data
CERT_ID="mc-api-prod"
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/$CERT_ID/export/pem" | jq .
```
**Expected:** 200 OK with JSON body containing `cert_pem` (leaf), `chain_pem` (CA certs), and `full_pem` (concatenated).
**PASS if:**
- Response Content-Type is `application/json`
- `cert_pem` contains exactly one `-----BEGIN CERTIFICATE-----` block
- `full_pem` starts with the same block as `cert_pem` (leaf is first in chain)
- `chain_pem` is empty for self-signed CA or contains the issuing CA cert
**FAIL if:** Response is non-JSON, fields are missing, or `full_pem` doesn't equal `cert_pem` + `chain_pem`.
### 29.2: Export PEM (File Download)
**What:** Adding `?download=true` to the PEM export endpoint returns the raw PEM file with `Content-Type: application/x-pem-file` and a `Content-Disposition: attachment` header, suitable for browser "Save As" workflows.
**Why:** The GUI uses this mode when operators click the "Export PEM" button — the browser should trigger a file download, not show JSON in the tab.
```bash
curl -s -D - -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/$CERT_ID/export/pem?download=true" \
-o /tmp/exported.pem
# Verify the downloaded file is valid PEM
openssl x509 -in /tmp/exported.pem -noout -subject
```
**Expected:** 200 OK, headers include `Content-Type: application/x-pem-file` and `Content-Disposition: attachment; filename="certificate.pem"`.
**PASS if:**
- The response headers match the expected Content-Type and Content-Disposition
- The saved file parses successfully with `openssl x509`
- The subject CN matches the certificate's common name
**FAIL if:** Headers are wrong (JSON Content-Type), file is empty, or `openssl` rejects the PEM.
### 29.3: Export PEM — Not Found
**What:** Requesting export for a nonexistent certificate ID returns 404.
```bash
curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/mc-nonexistent/export/pem"
```
**Expected:** 404 Not Found with error message.
**PASS if** status code is 404 and body contains "not found".
### 29.4: Export PKCS#12
**What:** `POST /api/v1/certificates/{id}/export/pkcs12` returns a binary PKCS#12 (.p12) file containing the certificate chain (no private key). An optional `password` field in the JSON body encrypts the bundle.
**Why:** PKCS#12 is the standard format for importing certificates into Java keystores (`keytool`), Windows certificate stores, and many commercial load balancers. The cert-only bundle (no private key) is safe to share with teams that only need trust anchors.
```bash
# Export with a password
curl -s -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"password": "export-test-2024"}' \
"http://localhost:8443/api/v1/certificates/$CERT_ID/export/pkcs12" \
-o /tmp/exported.p12
# Verify the PKCS#12 file (openssl should parse it)
openssl pkcs12 -in /tmp/exported.p12 -nokeys -passin pass:export-test-2024 -info
```
**Expected:** 200 OK, Content-Type `application/x-pkcs12`, Content-Disposition `attachment; filename="certificate.p12"`.
**PASS if:**
- Binary .p12 file is returned (non-empty)
- `openssl pkcs12` successfully parses the file with the correct password
- No private key is present in the output (cert-only trust store)
**FAIL if:** Response is JSON instead of binary, file is empty, or `openssl` rejects the PKCS#12 format.
### 29.5: Export PKCS#12 — Empty Password
**What:** The password field is optional. Omitting it (or sending an empty body) should still produce a valid PKCS#12 bundle encrypted with an empty password.
```bash
curl -s -H "Authorization: Bearer $API_KEY" \
-X POST \
"http://localhost:8443/api/v1/certificates/$CERT_ID/export/pkcs12" \
-o /tmp/exported-nopass.p12
openssl pkcs12 -in /tmp/exported-nopass.p12 -nokeys -passin pass: -info
```
**Expected:** 200 OK with valid PKCS#12.
**PASS if** `openssl pkcs12` parses with an empty password.
### 29.6: Export Audit Trail
**What:** Both PEM and PKCS#12 exports record audit events (`export_pem` and `export_pkcs12`) with the certificate's serial number.
**Why:** Export operations are security-sensitive — knowing who exported what and when is critical for incident response and compliance (SOC 2 CC7, PCI-DSS Req 10).
```bash
# Export a cert (triggers audit event)
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/$CERT_ID/export/pem" > /dev/null
# Check audit trail for the export event
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/audit?resource_type=certificate&action=export_pem" | jq '.items[-1]'
```
**Expected:** Audit event with action `export_pem`, resource_type `certificate`, resource_id matching the cert ID.
**PASS if** the audit event exists with serial number in metadata.
**FAIL if** no audit event is recorded for the export.
### 29.7: Export Unit Tests
```bash
go test ./internal/service/ -run TestExport -v
go test ./internal/api/handler/ -run TestExport -v
```
**Expected:** All export service tests (9 tests) and handler tests (11 tests) pass.
**PASS if** exit code 0 for both.
### 29.8: GUI Export Buttons
**What:** The certificate detail page shows "Export PEM" and "Export PKCS#12" buttons. PEM triggers a file download. PKCS#12 opens a password modal, then triggers a binary download.
**How to test (manual browser test):**
1. Navigate to a certificate detail page (e.g., `/certificates/mc-api-prod`)
2. Click "Export PEM" — browser should download `certificate.pem`
3. Click "Export PKCS#12" — password modal appears
4. Enter a password and confirm — browser should download `certificate.p12`
**PASS if** both downloads complete with non-empty files.
**FAIL if** buttons are missing, modal doesn't appear, or downloads fail.
---
## Part 30: S/MIME & EKU Support
**What:** Certificate profiles can specify Extended Key Usage (EKU) constraints — `serverAuth`, `clientAuth`, `codeSigning`, `emailProtection`, `timeStamping`. The Local CA respects these EKUs during issuance, adapting the X.509 `KeyUsage` flags accordingly (TLS uses `DigitalSignature|KeyEncipherment`; S/MIME uses `DigitalSignature|ContentCommitment`). A demo `prof-smime` profile ships in seed data.
**Why:** S/MIME certificates protect email with digital signatures and encryption. They require the `emailProtection` EKU and `ContentCommitment` (formerly NonRepudiation) key usage flag. If the platform treats all certs as TLS certs, S/MIME certs will be rejected by mail clients.
### 30.1: S/MIME Profile Exists in Seed Data
**What:** The demo seed creates 5 profiles including `prof-smime` with `emailProtection` EKU.
```bash
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/profiles/prof-smime" | jq '{name, allowed_ekus}'
```
**Expected:** 200 OK. Profile name is "S/MIME Email" and `allowed_ekus` contains `["emailProtection"]`.
**PASS if** the profile exists and EKUs match.
**FAIL if** 404 or EKUs are wrong/missing.
### 30.2: All Five Profiles Present
**What:** The seed data creates 5 profiles total. Previous versions of this guide referenced 4 — the `prof-smime` profile was added in M27.
```bash
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/profiles" | jq '.total'
```
**Expected:** `total` is 5 (prof-standard-tls, prof-internal-mtls, prof-short-lived, prof-wildcard, prof-smime).
**PASS if** count is 5.
**FAIL if** count is 4 or fewer (missing prof-smime).
### 30.3: EKU Strings in Profile API
**What:** The profile API accepts and returns EKU names as human-readable strings rather than OID numbers. The supported values are: `serverAuth`, `clientAuth`, `codeSigning`, `emailProtection`, `timeStamping`.
```bash
# Create a profile with codeSigning EKU
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "prof-test-codesign",
"name": "Code Signing Test",
"description": "Test profile for code signing",
"allowed_key_algorithms": [{"algorithm": "ECDSA", "min_size": 256}],
"max_ttl_seconds": 7776000,
"allowed_ekus": ["codeSigning"]
}' \
"http://localhost:8443/api/v1/profiles" | jq '{id, allowed_ekus}'
```
**Expected:** 201 Created with `allowed_ekus: ["codeSigning"]`.
**PASS if** the EKU round-trips correctly through create/get.
### 30.4: Agent CSR SAN Splitting (Email vs DNS)
**What:** When generating CSRs for S/MIME certificates, the agent splits SANs by type: values containing `@` are placed in `EmailAddresses` (not `DNSNames`). This prevents mail clients from rejecting the cert due to incorrect SAN encoding.
**Why:** An email SAN like `alice@example.com` must appear in the X.509 `rfc822Name` SAN field, not the `dNSName` field. Incorrect encoding causes S/MIME validation failures.
This is tested via unit tests:
```bash
go test ./cmd/agent/ -run TestSAN -v
```
**Expected:** Tests pass showing email-type SANs are routed to `EmailAddresses`.
**PASS if** exit code 0.
### 30.5: EKU Service-Layer Tests
```bash
go test ./internal/service/ -run TestEKU -v
go test ./internal/service/ -run TestCSRRenewal -v
```
**Expected:** Tests covering EKU resolution from profiles and issuance with non-default EKUs pass.
**PASS if** exit code 0.
---
## Part 31: OCSP Responder & DER CRL
**What:** certctl includes an embedded OCSP responder and a DER-encoded CRL generator, both operating per-issuer. These are the standard online (OCSP) and offline (CRL) methods for checking certificate revocation status. Short-lived certificates (profile TTL < 1 hour) are exempt from both — their natural expiry is sufficient revocation.
**Why:** TLS clients need to verify that certificates haven't been revoked. Without OCSP/CRL, a compromised certificate remains trusted until it expires. The short-lived exemption avoids bloating the CRL with certs that expire before distribution.
### 31.1: DER-Encoded CRL
**What:** `GET /api/v1/crl/{issuer_id}` returns a DER-encoded X.509 CRL signed by the issuing CA. Content-Type is `application/pkix-crl`. The CRL has 24-hour validity.
**Why:** This is the standard CRL format that browsers, TLS libraries, and LDAP directories consume. The existing JSON CRL at `GET /api/v1/crl` is certctl-specific; the DER CRL is interoperable.
```bash
# Request DER CRL for the local issuer
curl -s -D - -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/crl/iss-local" \
-o /tmp/crl.der
# Verify it's valid DER CRL with openssl
openssl crl -in /tmp/crl.der -inform DER -noout -text
```
**Expected:** 200 OK, Content-Type `application/pkix-crl`, Cache-Control `public, max-age=3600`.
**PASS if:**
- `openssl crl` parses the DER file successfully
- Issuer field shows the Local CA's common name
- Validity period is present (thisUpdate / nextUpdate)
- If any certs have been revoked, they appear in the revocation list with serial + reason
**FAIL if:** Response is JSON (wrong endpoint), `openssl` rejects the DER format, or headers are wrong.
### 31.2: DER CRL — Nonexistent Issuer
```bash
curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/crl/iss-nonexistent"
```
**Expected:** 404 Not Found.
**PASS if** status code is 404 and body contains "not found".
### 31.3: OCSP Responder — Good Status
**What:** `GET /api/v1/ocsp/{issuer_id}/{serial}` returns a signed OCSP response. For a non-revoked certificate, the status is "good".
**Why:** OCSP is the real-time revocation check that TLS clients perform during the handshake. A "good" response tells the client the cert is still valid.
```bash
# First, get a certificate's serial number
SERIAL=$(curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/mc-api-prod" | jq -r '.latest_version.serial_number // empty')
# If serial is available, query OCSP
if [ -n "$SERIAL" ]; then
curl -s -D - -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/ocsp/iss-local/$SERIAL" \
-o /tmp/ocsp.der
# Parse OCSP response
openssl ocsp -respin /tmp/ocsp.der -text -noverify
fi
```
**Expected:** 200 OK, Content-Type `application/ocsp-response`. OCSP response shows `Cert Status: good`.
**PASS if:**
- OCSP response parses successfully
- Certificate status is "good" for a non-revoked cert
- Response is signed (producedAt timestamp present)
**FAIL if:** Response is JSON, OCSP status is wrong, or `openssl` rejects the response.
### 31.4: OCSP Responder — Revoked Status
**What:** After revoking a certificate, the OCSP responder should return "revoked" with the revocation reason and timestamp.
```bash
# Revoke a certificate first (see Part 5 for revocation)
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "keyCompromise"}' \
"http://localhost:8443/api/v1/certificates/$CERT_ID/revoke"
# Then query OCSP
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/ocsp/iss-local/$SERIAL" \
-o /tmp/ocsp-revoked.der
openssl ocsp -respin /tmp/ocsp-revoked.der -text -noverify
```
**Expected:** OCSP response shows `Cert Status: revoked`, revocation time, and reason code (1 = keyCompromise).
**PASS if** status is "revoked" with correct reason.
**FAIL if** status is still "good" after revocation.
### 31.5: OCSP — Unknown Certificate
**What:** Querying a serial number that doesn't exist in the inventory returns an "unknown" OCSP status (not an error — this is the correct OCSP behavior per RFC 6960).
```bash
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/ocsp/iss-local/DEADBEEF" \
-o /tmp/ocsp-unknown.der
openssl ocsp -respin /tmp/ocsp-unknown.der -text -noverify
```
**Expected:** OCSP response with `Cert Status: unknown`.
**PASS if** status is "unknown" (not a 404 HTTP error).
### 31.6: Short-Lived Certificate CRL Exemption
**What:** Certificates issued under a profile with TTL < 1 hour are excluded from both CRL and OCSP responses. Their natural expiry is considered sufficient revocation.
**Why:** Short-lived certs (used in mTLS, CI/CD pipelines) would bloat the CRL with entries that expire within minutes. The crypto community consensus (per Google's Certificate Transparency policy) is that short-lived certs don't need revocation infrastructure.
To test: revoke a cert that was issued under the `prof-short-lived` profile, then check the DER CRL. The revoked short-lived cert should NOT appear.
```bash
# After revoking a short-lived cert (serial SHORT_SERIAL):
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/crl/iss-local" -o /tmp/crl.der
openssl crl -in /tmp/crl.der -inform DER -text | grep -i "$SHORT_SERIAL"
```
**Expected:** The short-lived cert's serial does NOT appear in the CRL.
**PASS if** short-lived cert is absent from CRL despite being revoked.
### 31.7: OCSP / CRL Unit Tests
```bash
go test ./internal/service/ -run "TestGenerateDERCRL|TestGetOCSPResponse" -v
go test ./internal/api/handler/ -run "TestDERCRL|TestOCSP" -v
go test ./internal/connector/issuer/local/ -run "TestGenerateCRL|TestSignOCSP" -v
```
**Expected:** All tests pass (8 service tests, handler tests, connector tests).
**PASS if** exit code 0 for all three test suites.
---
## Part 32: Request Body Size Limits
**What:** The `NewBodyLimit` middleware wraps request bodies with `http.MaxBytesReader`, enforcing a configurable maximum payload size (default 1MB). Oversized requests receive a 413 Request Entity Too Large response. This protects against memory exhaustion and denial of service (CWE-400).
**Why:** Without body limits, an attacker could send a multi-gigabyte POST to exhaust server memory. The 1MB default is generous for certificate API payloads (a typical CSR is ~1KB, a PKCS#12 export request is <100 bytes) while blocking abuse.
### 32.1: Default 1MB Limit
**What:** With default configuration (`CERTCTL_MAX_BODY_SIZE` unset), the server rejects request bodies larger than 1MB.
```bash
# Generate a payload slightly over 1MB
dd if=/dev/urandom bs=1024 count=1025 2>/dev/null | base64 > /tmp/big-payload.txt
curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$(cat /tmp/big-payload.txt)\"}" \
"http://localhost:8443/api/v1/certificates"
```
**Expected:** The server returns an error (likely 400 or 413) when the body exceeds 1MB.
**PASS if** the request is rejected and does not cause server memory issues.
**FAIL if** the server accepts the oversized payload or crashes.
### 32.2: Normal-Sized Requests Work
**What:** Standard API requests well under the limit work normally.
```bash
curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "mc-test-bodylimit", "common_name": "bodylimit.test.local", "issuer_id": "iss-local"}' \
"http://localhost:8443/api/v1/certificates"
```
**Expected:** 201 Created — normal payloads are unaffected by the body limit.
**PASS if** status code is 201.
### 32.3: Custom Body Size via Environment Variable
**What:** Set `CERTCTL_MAX_BODY_SIZE` to a custom value (e.g., `2097152` for 2MB) and verify the new limit is respected.
**How:** Restart the server with the env var set, then repeat test 32.1. A 1.1MB payload should now be accepted; a 2.1MB payload should be rejected.
**PASS if** the configured limit is enforced instead of the 1MB default.
### 32.4: Requests Without Bodies Are Unaffected
**What:** GET requests and other methods without request bodies pass through the body limit middleware without interference.
```bash
curl -s -w "\n%{http_code}" -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates" | tail -1
```
**Expected:** 200 OK — body limit middleware only applies to requests with bodies.
**PASS if** GET requests are unaffected.
---
## Part 33: Apache & HAProxy Target Connectors
**What:** certctl ships two additional target connectors beyond NGINX: Apache httpd (separate cert/chain/key files, `apachectl configtest` + graceful reload) and HAProxy (combined PEM file with cert+chain+key, config validation, reload). Both run on the agent side and follow the same pattern as the NGINX connector.
**Why:** Apache and HAProxy are the second and third most common reverse proxies in enterprise environments. Supporting them out of the box removes a common adoption blocker.
### 33.1: Create Apache Target
**What:** Create a deployment target of type `apache` with the required configuration fields.
```bash
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "t-test-apache",
"name": "Test Apache Server",
"type": "apache",
"agent_id": "agent-demo-1",
"config": {
"cert_path": "/etc/apache2/ssl/cert.pem",
"key_path": "/etc/apache2/ssl/key.pem",
"chain_path": "/etc/apache2/ssl/chain.pem",
"reload_command": "apachectl graceful",
"validate_command": "apachectl configtest"
}
}' \
"http://localhost:8443/api/v1/targets" | jq '{id, name, type}'
```
**Expected:** 201 Created with type `apache`.
**PASS if:**
- Target is created successfully
- Type is `apache`
- Config fields are persisted (verify via GET)
**FAIL if** type is rejected or config fields are missing in the response.
### 33.2: Apache Config — Separate Files
**What:** Apache uses three separate files (cert, chain, key) unlike NGINX's dual-file or HAProxy's combined PEM. Verify that `cert_path`, `chain_path`, and `key_path` are all required.
```bash
# Missing chain_path should fail validation
curl -s -w "\n%{http_code}" -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "t-test-apache-bad",
"name": "Bad Apache",
"type": "apache",
"agent_id": "agent-demo-1",
"config": {
"cert_path": "/etc/apache2/ssl/cert.pem",
"reload_command": "apachectl graceful",
"validate_command": "apachectl configtest"
}
}' \
"http://localhost:8443/api/v1/targets"
```
**Expected:** The target is created (config validation happens at deploy time on the agent), but when the agent attempts to deploy, it will fail if required fields are missing.
**PASS if** the validation behavior matches the connector's `ValidateConfig``cert_path` and `chain_path` are both required.
### 33.3: Create HAProxy Target
**What:** Create a deployment target of type `haproxy`. HAProxy uses a single combined PEM file (cert + chain + key concatenated), not separate files.
```bash
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "t-test-haproxy",
"name": "Test HAProxy",
"type": "haproxy",
"agent_id": "agent-demo-1",
"config": {
"pem_path": "/etc/haproxy/certs/site.pem",
"reload_command": "systemctl reload haproxy",
"validate_command": "haproxy -c -f /etc/haproxy/haproxy.cfg"
}
}' \
"http://localhost:8443/api/v1/targets" | jq '{id, name, type}'
```
**Expected:** 201 Created with type `haproxy`.
**PASS if** target created with correct type and config persisted.
### 33.4: HAProxy Combined PEM Requirement
**What:** HAProxy's `pem_path` is the single file where cert+chain+key are concatenated. The `pem_path` field is required; `reload_command` is also required.
**Why:** HAProxy's `bind ssl crt` directive expects one file per certificate. The combined PEM format eliminates the need for multiple `SSLCertificate*` directives.
This is verified in the connector's `ValidateConfig`:
```bash
go test ./internal/connector/target/haproxy/... -v
```
**Expected:** Tests validate that missing `pem_path` and missing `reload_command` both produce errors.
**PASS if** all haproxy connector tests pass.
### 33.5: Shell Command Injection Prevention
**What:** Both Apache and HAProxy connectors validate `reload_command` and `validate_command` against the shell injection prevention logic in `internal/validation/command.go`. Commands containing shell metacharacters (`;`, `|`, `&`, `$()`, backticks) are rejected.
**Why:** An attacker who controls target configuration could inject arbitrary commands if the reload/validate commands aren't sanitized. This was remediated in the security hardening pass (TICKET-001).
```bash
go test ./internal/validation/ -run TestValidateShellCommand -v
```
**Expected:** All 80+ adversarial test cases pass — commands with injection attempts are rejected, safe commands are accepted.
**PASS if** exit code 0.
### 33.6: Connector Unit Tests
```bash
go test ./internal/connector/target/apache/... -v
go test ./internal/connector/target/haproxy/... -v
```
**Expected:** All Apache and HAProxy connector tests pass (config validation, deployment logic).
**PASS if** exit code 0 for both.
---
## Part 34: Sub-CA Mode
**What:** The Local CA issuer connector can operate in two modes: self-signed root (default) or sub-CA. In sub-CA mode, set `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH` to point at a pre-signed CA certificate and its private key. The CA cert must have `IsCA=true` and `KeyUsageCertSign`. All issued certificates then chain to the upstream root (e.g., Active Directory Certificate Services). Supports RSA, ECDSA, and PKCS#8 key formats.
**Why:** Enterprise environments already have a root CA (ADCS, Vault, etc.). Sub-CA mode lets certctl operate as a subordinate CA without replacing the existing trust hierarchy. Users' browsers and devices already trust the enterprise root, so certctl-issued certs are automatically trusted.
### 34.1: Self-Signed Mode (Default)
**What:** Without `CERTCTL_CA_CERT_PATH` / `CERTCTL_CA_KEY_PATH`, the Local CA generates its own self-signed root on startup. This is the default for development and demos.
```bash
# Verify the CA cert is self-signed (issuer == subject)
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/mc-api-prod/export/pem?download=true" \
-o /tmp/chain.pem
# Extract the last cert in the chain (the CA cert)
csplit -f /tmp/cert- -z /tmp/chain.pem '/-----BEGIN CERTIFICATE-----/' '{*}' 2>/dev/null
LAST_CERT=$(ls /tmp/cert-* | tail -1)
openssl x509 -in "$LAST_CERT" -noout -subject -issuer
```
**Expected:** For self-signed mode, the CA cert's Subject and Issuer are identical.
**PASS if** Subject == Issuer (self-signed root).
### 34.2: Sub-CA Mode — Configuration
**What:** Setting `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH` environment variables switches the Local CA to sub-CA mode. The server logs the mode at startup.
**How to test:**
1. Generate a test CA hierarchy (root CA + sub-CA):
```bash
# Generate root CA
openssl req -x509 -newkey rsa:2048 -keyout /tmp/root-key.pem -out /tmp/root-cert.pem \
-days 3650 -nodes -subj "/CN=Test Root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
# Generate sub-CA key and CSR
openssl req -newkey rsa:2048 -keyout /tmp/subca-key.pem -out /tmp/subca-csr.pem \
-nodes -subj "/CN=CertCtl Sub-CA"
# Sign sub-CA cert with root
openssl x509 -req -in /tmp/subca-csr.pem -CA /tmp/root-cert.pem -CAkey /tmp/root-key.pem \
-CAcreateserial -out /tmp/subca-cert.pem -days 1825 \
-extfile <(echo -e "basicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign")
```
2. Start the server with sub-CA config:
```bash
CERTCTL_CA_CERT_PATH=/tmp/subca-cert.pem \
CERTCTL_CA_KEY_PATH=/tmp/subca-key.pem \
./certctl-server
```
3. Check startup logs for sub-CA mode indication.
**PASS if** the server starts successfully and logs indicate sub-CA mode with the loaded cert path.
**FAIL if** the server fails to start or falls back to self-signed mode.
### 34.3: Sub-CA Chain Construction
**What:** In sub-CA mode, issued certificates should chain to the sub-CA, which chains to the root. The PEM chain in certificate versions should include the leaf, the sub-CA cert, and optionally the root.
```bash
# Issue a certificate (after starting in sub-CA mode)
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "mc-subca-test", "common_name": "subca.test.local", "issuer_id": "iss-local"}' \
"http://localhost:8443/api/v1/certificates"
# Export and verify chain
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/certificates/mc-subca-test/export/pem" | jq -r '.full_pem' > /tmp/subca-chain.pem
openssl verify -CAfile /tmp/root-cert.pem -untrusted /tmp/subca-cert.pem /tmp/subca-chain.pem
```
**Expected:** Certificate chain validates against the root CA. The leaf cert's Issuer matches the sub-CA's Subject.
**PASS if** `openssl verify` returns "OK".
**FAIL if** chain is broken or leaf is signed by self-signed root instead of sub-CA.
### 34.4: Sub-CA Validation — Non-CA Cert Rejected
**What:** If `CERTCTL_CA_CERT_PATH` points to a certificate without `IsCA=true` or `KeyUsageCertSign`, the server should reject it at startup.
```bash
# Generate a non-CA cert (leaf cert, not a CA)
openssl req -x509 -newkey rsa:2048 -keyout /tmp/leaf-key.pem -out /tmp/leaf-cert.pem \
-days 365 -nodes -subj "/CN=Not A CA"
# Try to start server with non-CA cert — should fail
CERTCTL_CA_CERT_PATH=/tmp/leaf-cert.pem \
CERTCTL_CA_KEY_PATH=/tmp/leaf-key.pem \
./certctl-server
```
**Expected:** Server fails to start (or logs a fatal error) because the loaded cert is not a CA.
**PASS if** server rejects the non-CA certificate.
**FAIL if** server starts and silently uses the non-CA cert for signing.
### 34.5: Sub-CA Key Format Support
**What:** The sub-CA key can be RSA, ECDSA, or PKCS#8 encoded. All three formats should load successfully.
```bash
go test ./internal/connector/issuer/local/ -run "TestSubCA" -v
```
**Expected:** All 7 sub-CA tests pass (RSA, ECDSA, config validation, invalid cert, non-CA cert, renewal, chain construction).
**PASS if** exit code 0.
### 34.6: CRL Signing in Sub-CA Mode
**What:** In sub-CA mode, the DER CRL (Part 31.1) should be signed by the sub-CA key, not a self-signed root.
```bash
# After starting in sub-CA mode and revoking a cert:
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/crl/iss-local" -o /tmp/subca-crl.der
openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
```
**Expected:** CRL issuer matches the sub-CA's subject (not the self-signed CA).
**PASS if** issuer is the sub-CA distinguished name.
---
## Release Sign-Off
All 28 parts must pass before tagging v2.0.7.
All 34 parts must pass before tagging v2.1.0.
| Section | Pass? | Tester | Date | Notes |
|---------|-------|--------|------|-------|
@@ -4401,6 +5103,12 @@ All 28 parts must pass before tagging v2.0.7.
| Part 26: EST Server (RFC 7030) | ☐ | | | |
| Part 27: Post-Deployment TLS Verification | ☐ | | | |
| Part 28: Traefik & Caddy Target Connectors | ☐ | | | |
| Part 29: Certificate Export (PEM & PKCS#12) | ☐ | | | |
| Part 30: S/MIME & EKU Support | ☐ | | | |
| Part 31: OCSP Responder & DER CRL | ☐ | | | |
| Part 32: Request Body Size Limits | ☐ | | | |
| Part 33: Apache & HAProxy Target Connectors | ☐ | | | |
| Part 34: Sub-CA Mode | ☐ | | | |
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
+76
View File
@@ -0,0 +1,76 @@
package handler
import (
"context"
"encoding/json"
"net/http"
)
// DigestServicer defines the interface for digest operations used by the handler.
type DigestServicer interface {
PreviewDigest(ctx context.Context) (string, error)
SendDigest(ctx context.Context) error
}
// DigestHandler provides HTTP endpoints for certificate digest operations.
type DigestHandler struct {
service DigestServicer
}
// NewDigestHandler creates a new digest handler.
func NewDigestHandler(service DigestServicer) *DigestHandler {
return &DigestHandler{service: service}
}
// PreviewDigest renders the digest HTML without sending it.
// GET /api/v1/digest/preview
func (h *DigestHandler) PreviewDigest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if h.service == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"error": "digest service not configured"})
return
}
html, err := h.service.PreviewDigest(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(html))
}
// SendDigest triggers an immediate digest send.
// POST /api/v1/digest/send
func (h *DigestHandler) SendDigest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if h.service == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"error": "digest service not configured"})
return
}
if err := h.service.SendDigest(r.Context()); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "sent"})
}
+157
View File
@@ -0,0 +1,157 @@
package handler
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// mockDigestService implements DigestServicer for testing.
type mockDigestService struct {
previewHTML string
previewErr error
sendErr error
sendCalled bool
}
func (m *mockDigestService) PreviewDigest(ctx context.Context) (string, error) {
if m.previewErr != nil {
return "", m.previewErr
}
return m.previewHTML, nil
}
func (m *mockDigestService) SendDigest(ctx context.Context) error {
m.sendCalled = true
return m.sendErr
}
func TestDigestHandler_PreviewDigest_Success(t *testing.T) {
svc := &mockDigestService{
previewHTML: "<html><body>Digest Preview</body></html>",
}
h := NewDigestHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/preview", nil)
w := httptest.NewRecorder()
h.PreviewDigest(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
if w.Header().Get("Content-Type") != "text/html; charset=utf-8" {
t.Errorf("expected Content-Type text/html, got %s", w.Header().Get("Content-Type"))
}
if w.Body.String() != "<html><body>Digest Preview</body></html>" {
t.Errorf("unexpected body: %s", w.Body.String())
}
}
func TestDigestHandler_PreviewDigest_MethodNotAllowed(t *testing.T) {
svc := &mockDigestService{}
h := NewDigestHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/preview", nil)
w := httptest.NewRecorder()
h.PreviewDigest(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status 405, got %d", w.Code)
}
}
func TestDigestHandler_PreviewDigest_ServiceError(t *testing.T) {
svc := &mockDigestService{
previewErr: errors.New("stats unavailable"),
}
h := NewDigestHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/preview", nil)
w := httptest.NewRecorder()
h.PreviewDigest(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestDigestHandler_PreviewDigest_NotConfigured(t *testing.T) {
h := NewDigestHandler(nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/preview", nil)
w := httptest.NewRecorder()
h.PreviewDigest(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected status 503, got %d", w.Code)
}
}
func TestDigestHandler_SendDigest_Success(t *testing.T) {
svc := &mockDigestService{}
h := NewDigestHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/send", nil)
w := httptest.NewRecorder()
h.SendDigest(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
if !svc.sendCalled {
t.Error("expected SendDigest to be called")
}
}
func TestDigestHandler_SendDigest_MethodNotAllowed(t *testing.T) {
svc := &mockDigestService{}
h := NewDigestHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/digest/send", nil)
w := httptest.NewRecorder()
h.SendDigest(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status 405, got %d", w.Code)
}
}
func TestDigestHandler_SendDigest_ServiceError(t *testing.T) {
svc := &mockDigestService{
sendErr: errors.New("SMTP connection refused"),
}
h := NewDigestHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/send", nil)
w := httptest.NewRecorder()
h.SendDigest(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestDigestHandler_SendDigest_NotConfigured(t *testing.T) {
h := NewDigestHandler(nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/digest/send", nil)
w := httptest.NewRecorder()
h.SendDigest(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected status 503, got %d", w.Code)
}
}
@@ -610,3 +610,122 @@ func TestGetDiscoverySummary_MethodNotAllowed(t *testing.T) {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// Test DismissDiscovered - service error
func TestDismissDiscovered_ServiceError(t *testing.T) {
mock := &MockDiscoveryService{
DismissDiscoveredFn: func(ctx context.Context, id string) error {
return fmt.Errorf("database error")
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/dcert-1/dismiss", nil)
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "dcert-1")
w := httptest.NewRecorder()
handler.DismissDiscovered(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
// Test ClaimDiscovered - invalid body (malformed JSON)
func TestClaimDiscovered_InvalidJSON(t *testing.T) {
mock := &MockDiscoveryService{}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/discovered-certificates/dcert-1/claim", bytes.NewReader([]byte("invalid json")))
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "dcert-1")
w := httptest.NewRecorder()
handler.ClaimDiscovered(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
// Test ClaimDiscovered - method not allowed
func TestClaimDiscovered_MethodNotAllowed(t *testing.T) {
mock := &MockDiscoveryService{}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates/dcert-1/claim", nil)
req = req.WithContext(discoveryContextWithRequestID())
req.SetPathValue("id", "dcert-1")
w := httptest.NewRecorder()
handler.ClaimDiscovered(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// Test ListDiscovered - service error
func TestListDiscovered_ServiceError(t *testing.T) {
mock := &MockDiscoveryService{
ListDiscoveredFn: func(ctx context.Context, agentID, status string, page, perPage int) ([]*domain.DiscoveredCertificate, int, error) {
return nil, 0, fmt.Errorf("database error")
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovered-certificates", nil)
req = req.WithContext(discoveryContextWithRequestID())
w := httptest.NewRecorder()
handler.ListDiscovered(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
// Test ListScans - service error
func TestListScans_ServiceError(t *testing.T) {
mock := &MockDiscoveryService{
ListScansFn: func(ctx context.Context, agentID string, page, perPage int) ([]*domain.DiscoveryScan, int, error) {
return nil, 0, fmt.Errorf("database error")
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovery-scans", nil)
req = req.WithContext(discoveryContextWithRequestID())
w := httptest.NewRecorder()
handler.ListScans(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
// Test GetDiscoverySummary - service error
func TestGetDiscoverySummary_ServiceError(t *testing.T) {
mock := &MockDiscoveryService{
GetDiscoverySummaryFn: func(ctx context.Context) (map[string]int, error) {
return nil, fmt.Errorf("database error")
},
}
handler := NewDiscoveryHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/discovery-summary", nil)
req = req.WithContext(discoveryContextWithRequestID())
w := httptest.NewRecorder()
handler.GetDiscoverySummary(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
}
}
+46
View File
@@ -396,3 +396,49 @@ func TestASN1EncodeLength(t *testing.T) {
}
}
}
func TestESTCSRAttrs_ServiceError(t *testing.T) {
svc := &mockESTService{
CSRAttrsErr: errors.New("service error"),
}
h := NewESTHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil)
w := httptest.NewRecorder()
h.CSRAttrs(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
func TestESTSimpleReEnroll_ServiceError(t *testing.T) {
csrPEM := generateTestCSRPEM(t)
svc := &mockESTService{
EnrollErr: errors.New("renewal failed"),
}
h := NewESTHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simplereenroll", strings.NewReader(csrPEM))
w := httptest.NewRecorder()
h.SimpleReEnroll(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
func TestESTCACerts_UnableToGetCerts(t *testing.T) {
svc := &mockESTService{
CACertErr: errors.New("CA unavailable"),
}
h := NewESTHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil)
w := httptest.NewRecorder()
h.CACerts(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
@@ -12,6 +12,8 @@ import (
"github.com/shankar0123/certctl/internal/service"
)
// Add context import was already there — verify import is present above
// MockExportService is a mock implementation of ExportService interface.
type MockExportService struct {
ExportPEMFn func(ctx context.Context, certID string) (*service.ExportPEMResult, error)
@@ -280,3 +282,38 @@ func TestExtractCertIDFromExportPath(t *testing.T) {
}
}
}
func TestExportPKCS12_InvalidJSON(t *testing.T) {
mockSvc := &MockExportService{
ExportPKCS12Fn: func(_ context.Context, _ string, password string) ([]byte, error) {
// Invalid JSON is silently ignored, defaults to empty password
if password != "" {
t.Errorf("expected empty password (invalid JSON ignored), got %s", password)
}
return []byte{0x30}, nil
},
}
h := NewExportHandler(mockSvc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", strings.NewReader(`{"invalid json`))
w := httptest.NewRecorder()
h.ExportPKCS12(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (invalid JSON ignored), got %d", w.Code)
}
}
func TestExportPEM_MethodNotAllowedDelete(t *testing.T) {
h := NewExportHandler(&MockExportService{})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-test-1/export/pem", nil)
w := httptest.NewRecorder()
h.ExportPEM(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", w.Code)
}
}
+112
View File
@@ -316,3 +316,115 @@ func TestGetPrometheusMetrics_ZeroValues(t *testing.T) {
func containsLine(text, substr string) bool {
return strings.Contains(text, substr)
}
// Test GetCertificatesByStatus - method not allowed
func TestGetCertificatesByStatus_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/certificates-by-status", nil)
w := httptest.NewRecorder()
h.GetCertificatesByStatus(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
// Test GetCertificatesByStatus - service error
func TestGetCertificatesByStatus_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetCertificatesByStatusFn: func(ctx context.Context) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/certificates-by-status", nil)
w := httptest.NewRecorder()
h.GetCertificatesByStatus(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
// Test GetExpirationTimeline - method not allowed
func TestGetExpirationTimeline_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/expiration-timeline", nil)
w := httptest.NewRecorder()
h.GetExpirationTimeline(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
// Test GetExpirationTimeline - service error
func TestGetExpirationTimeline_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetExpirationTimelineFn: func(ctx context.Context, days int) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/expiration-timeline?days=30", nil)
w := httptest.NewRecorder()
h.GetExpirationTimeline(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
// Test GetJobTrends - method not allowed
func TestGetJobTrends_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/job-trends", nil)
w := httptest.NewRecorder()
h.GetJobTrends(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
// Test GetJobTrends - service error
func TestGetJobTrends_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetJobStatsFn: func(ctx context.Context, days int) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/job-trends?days=14", nil)
w := httptest.NewRecorder()
h.GetJobTrends(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
// Test GetIssuanceRate - method not allowed
func TestGetIssuanceRate_MethodNotAllowed(t *testing.T) {
mock := &MockStatsService{}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/stats/issuance-rate", nil)
w := httptest.NewRecorder()
h.GetIssuanceRate(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
// Test GetIssuanceRate - service error
func TestGetIssuanceRate_ServiceError(t *testing.T) {
mock := &MockStatsService{
GetIssuanceRateFn: func(ctx context.Context, days int) (interface{}, error) {
return nil, fmt.Errorf("db error")
},
}
h := NewStatsHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/stats/issuance-rate?days=7", nil)
w := httptest.NewRecorder()
h.GetIssuanceRate(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
}
@@ -249,6 +249,58 @@ func TestVerifyDeployment_ServiceError(t *testing.T) {
}
}
func TestVerifyDeployment_EmptyBody(t *testing.T) {
mockSvc := &mockVerificationService{}
handler := NewVerificationHandler(mockSvc)
httpReq := httptest.NewRequest("POST", "/api/v1/jobs/j-test10/verify", bytes.NewBufferString(""))
w := httptest.NewRecorder()
handler.VerifyDeployment(w, httpReq)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
func TestGetVerificationStatus_ServiceError(t *testing.T) {
mockSvc := &mockVerificationService{
getErr: ErrServiceUnavailable,
}
handler := NewVerificationHandler(mockSvc)
httpReq := httptest.NewRequest("GET", "/api/v1/jobs/j-test11/verification", nil)
w := httptest.NewRecorder()
handler.GetVerificationStatus(w, httpReq)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", w.Code)
}
}
func TestGetVerificationStatus_NotFound(t *testing.T) {
mockSvc := &mockVerificationService{
results: make(map[string]*domain.VerificationResult),
}
handler := NewVerificationHandler(mockSvc)
httpReq := httptest.NewRequest("GET", "/api/v1/jobs/j-nonexistent/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 != nil {
t.Error("expected nil result for nonexistent job")
}
}
var ErrServiceUnavailable = NewServiceError("service unavailable")
func NewServiceError(msg string) error {
+5
View File
@@ -64,6 +64,7 @@ type HandlerRegistry struct {
NetworkScan handler.NetworkScanHandler
Verification handler.VerificationHandler
Export handler.ExportHandler
Digest handler.DigestHandler
}
// RegisterHandlers sets up all API routes with their handlers.
@@ -220,6 +221,10 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment))
r.Register("GET /api/v1/jobs/{id}/verification", http.HandlerFunc(reg.Verification.GetVerificationStatus))
// Digest routes: /api/v1/digest
r.Register("GET /api/v1/digest/preview", http.HandlerFunc(reg.Digest.PreviewDigest))
r.Register("POST /api/v1/digest/send", http.HandlerFunc(reg.Digest.SendDigest))
}
// RegisterESTHandlers sets up EST (RFC 7030) routes under /.well-known/est/.
+75
View File
@@ -24,6 +24,8 @@ type Config struct {
NetworkScan NetworkScanConfig
EST ESTConfig
Verification VerificationConfig
ACME ACMEConfig
Digest DigestConfig
}
// NotifierConfig contains configuration for notification connectors.
@@ -64,6 +66,34 @@ type NotifierConfig struct {
// OpsGeniePriority sets the default priority for OpsGenie alerts.
// Valid values: "P1", "P2", "P3", "P4", "P5". Default: "P3".
OpsGeniePriority string
// SMTPHost is the SMTP server hostname for sending email notifications.
// Example: "smtp.gmail.com", "smtp.sendgrid.net". Required for email notifications.
// Setting: CERTCTL_SMTP_HOST environment variable.
SMTPHost string
// SMTPPort is the SMTP server port. Default: 587 (STARTTLS).
// Common values: 25 (plain), 465 (implicit TLS), 587 (STARTTLS).
// Setting: CERTCTL_SMTP_PORT environment variable.
SMTPPort int
// SMTPUsername is the SMTP authentication username.
// Setting: CERTCTL_SMTP_USERNAME environment variable.
SMTPUsername string
// SMTPPassword is the SMTP authentication password or app-specific password.
// Setting: CERTCTL_SMTP_PASSWORD environment variable.
SMTPPassword string
// SMTPFromAddress is the sender email address for outbound notifications.
// Example: "certctl@example.com", "noreply@company.com".
// Setting: CERTCTL_SMTP_FROM_ADDRESS environment variable.
SMTPFromAddress string
// SMTPUseTLS enables TLS for the SMTP connection.
// Default: true. Set to false for plain SMTP (not recommended).
// Setting: CERTCTL_SMTP_USE_TLS environment variable.
SMTPUseTLS bool
}
// KeygenConfig controls where private keys are generated.
@@ -111,6 +141,24 @@ type StepCAConfig struct {
ProvisionerPassword string
}
// DigestConfig controls the scheduled certificate digest email feature.
type DigestConfig struct {
// Enabled controls whether periodic digest emails are generated and sent.
// Default: false. When enabled, requires SMTP to be configured.
// Setting: CERTCTL_DIGEST_ENABLED environment variable.
Enabled bool
// Interval is how often digest emails are generated and sent.
// Default: 24 hours. Minimum: 1 hour.
// Setting: CERTCTL_DIGEST_INTERVAL environment variable.
Interval time.Duration
// Recipients is a comma-separated list of email addresses to receive digest emails.
// If empty, digests are sent to all certificate owners.
// Setting: CERTCTL_DIGEST_RECIPIENTS environment variable.
Recipients []string
}
// ACMEConfig contains ACME issuer connector configuration.
type ACMEConfig struct {
// DirectoryURL is the ACME directory URL for certificate issuance.
@@ -144,6 +192,13 @@ type ACMEConfig struct {
// Example: "letsencrypt.org" or "zerossl.com". Only used if ChallengeType is "dns-persist-01".
// The record value becomes: "<issuer_domain>; accounturi=<acme_account_uri>"
DNSPersistIssuerDomain string
// ARIEnabled enables ACME Renewal Information (RFC 9702) support.
// When enabled, the renewal scheduler queries the CA for suggested renewal windows
// instead of relying solely on static expiration thresholds.
// Default: false. Requires a CA that supports ARI (e.g., Let's Encrypt).
// Setting: CERTCTL_ACME_ARI_ENABLED environment variable.
ARIEnabled bool
}
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
@@ -349,6 +404,12 @@ func Load() (*Config, error) {
PagerDutySeverity: getEnv("CERTCTL_PAGERDUTY_SEVERITY", "warning"),
OpsGenieAPIKey: getEnv("CERTCTL_OPSGENIE_API_KEY", ""),
OpsGeniePriority: getEnv("CERTCTL_OPSGENIE_PRIORITY", "P3"),
SMTPHost: getEnv("CERTCTL_SMTP_HOST", ""),
SMTPPort: getEnvInt("CERTCTL_SMTP_PORT", 587),
SMTPUsername: getEnv("CERTCTL_SMTP_USERNAME", ""),
SMTPPassword: getEnv("CERTCTL_SMTP_PASSWORD", ""),
SMTPFromAddress: getEnv("CERTCTL_SMTP_FROM_ADDRESS", ""),
SMTPUseTLS: getEnvBool("CERTCTL_SMTP_USE_TLS", true),
},
NetworkScan: NetworkScanConfig{
Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false),
@@ -364,6 +425,20 @@ func Load() (*Config, error) {
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
Delay: getEnvDuration("CERTCTL_VERIFY_DELAY", 2*time.Second),
},
ACME: ACMEConfig{
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
ChallengeType: getEnv("CERTCTL_ACME_CHALLENGE_TYPE", "http-01"),
DNSPresentScript: getEnv("CERTCTL_ACME_DNS_PRESENT_SCRIPT", ""),
DNSCleanUpScript: getEnv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT", ""),
DNSPersistIssuerDomain: getEnv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN", ""),
ARIEnabled: getEnvBool("CERTCTL_ACME_ARI_ENABLED", false),
},
Digest: DigestConfig{
Enabled: getEnvBool("CERTCTL_DIGEST_ENABLED", false),
Interval: getEnvDuration("CERTCTL_DIGEST_INTERVAL", 24*time.Hour),
Recipients: getEnvList("CERTCTL_DIGEST_RECIPIENTS", nil),
},
}
if err := cfg.Validate(); err != nil {
+4
View File
@@ -54,6 +54,10 @@ type Config struct {
// Used to construct the TXT record value: "<issuer-domain>; accounturi=<account-uri>".
// Required when ChallengeType is "dns-persist-01". For Let's Encrypt, use "letsencrypt.org".
DNSPersistIssuerDomain string `json:"dns_persist_issuer_domain,omitempty"`
// ARIEnabled enables ACME Renewal Information (RFC 9702) support per CERTCTL_ACME_ARI_ENABLED.
// When enabled, the connector queries the CA's ARI endpoint to get CA-directed renewal timing.
ARIEnabled bool `json:"ari_enabled,omitempty"`
}
// Connector implements the issuer.Connector interface for ACME-compatible CAs
+167
View File
@@ -0,0 +1,167 @@
package acme
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9702 for a certificate.
// certPEM is the PEM-encoded certificate. Returns nil, nil if the CA does not support ARI.
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
if !c.config.ARIEnabled {
return nil, nil
}
if err := c.ensureClient(ctx); err != nil {
return nil, fmt.Errorf("ACME client init: %w", err)
}
// Parse the certificate to compute the ARI certificate ID
certID, err := computeARICertID(certPEM)
if err != nil {
return nil, fmt.Errorf("failed to compute ARI cert ID: %w", err)
}
c.logger.Debug("retrieving ARI for certificate",
"cert_id", certID)
// Fetch the ACME directory to find the renewalInfo endpoint
renewalInfoURL, err := c.getARIEndpoint(ctx, certID)
if err != nil {
return nil, fmt.Errorf("failed to construct ARI endpoint: %w", err)
}
c.logger.Debug("querying ARI endpoint", "url", renewalInfoURL)
// Make GET request to the ARI endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodGet, renewalInfoURL, nil)
if err != nil {
return nil, fmt.Errorf("create ARI request: %w", err)
}
httpClient := &http.Client{Timeout: 15 * time.Second}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("ARI request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read ARI response: %w", err)
}
// 404 means the CA doesn't support ARI or the cert doesn't exist
if resp.StatusCode == http.StatusNotFound {
c.logger.Debug("ARI not supported by CA or cert not found")
return nil, nil
}
// Other non-2xx errors
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("ARI endpoint returned status %d: %s", resp.StatusCode, string(body))
}
// Parse the ARI response
var ariResp struct {
SuggestedWindow struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
} `json:"suggestedWindow"`
RetryAfter time.Time `json:"retryAfter,omitempty"`
ExplanationURL string `json:"explanationURL,omitempty"`
}
if err := json.Unmarshal(body, &ariResp); err != nil {
return nil, fmt.Errorf("parse ARI response: %w", err)
}
if ariResp.SuggestedWindow.Start.IsZero() || ariResp.SuggestedWindow.End.IsZero() {
return nil, fmt.Errorf("invalid ARI response: missing or empty suggestedWindow")
}
c.logger.Info("retrieved ARI",
"window_start", ariResp.SuggestedWindow.Start,
"window_end", ariResp.SuggestedWindow.End)
return &issuer.RenewalInfoResult{
SuggestedWindowStart: ariResp.SuggestedWindow.Start,
SuggestedWindowEnd: ariResp.SuggestedWindow.End,
RetryAfter: ariResp.RetryAfter,
ExplanationURL: ariResp.ExplanationURL,
}, nil
}
// computeARICertID computes the ARI certificate ID as defined in RFC 9702.
// The cert ID is base64url(SHA256(DER encoding of the certificate)).
func computeARICertID(certPEM string) (string, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return "", fmt.Errorf("invalid PEM: no certificate block found")
}
hash := sha256.Sum256(block.Bytes)
certID := base64.RawURLEncoding.EncodeToString(hash[:])
return certID, nil
}
// getARIEndpoint constructs the ARI endpoint URL from the ACME directory.
// It fetches the directory JSON and extracts the "renewalInfo" field if available.
// Falls back to a standard URL pattern if the directory doesn't advertise renewalInfo.
func (c *Connector) getARIEndpoint(ctx context.Context, certID string) (string, error) {
// Try to fetch and parse the directory
httpClient := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.DirectoryURL, nil)
if err != nil {
return "", fmt.Errorf("create directory request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
// If we can't fetch the directory, try the standard Let's Encrypt pattern
return constructARIURLFallback(c.config.DirectoryURL, certID), nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return constructARIURLFallback(c.config.DirectoryURL, certID), nil
}
var dir struct {
RenewalInfo string `json:"renewalInfo,omitempty"`
}
if err := json.Unmarshal(body, &dir); err != nil {
// Malformed directory; use fallback
return constructARIURLFallback(c.config.DirectoryURL, certID), nil
}
if dir.RenewalInfo != "" {
// Directory advertises renewalInfo endpoint
return dir.RenewalInfo + "/" + certID, nil
}
// No renewalInfo in directory; use standard fallback
return constructARIURLFallback(c.config.DirectoryURL, certID), nil
}
// constructARIURLFallback builds an ARI endpoint URL using a standard pattern.
// It replaces "/directory" with "/renewalInfo" in the URL.
func constructARIURLFallback(directoryURL, certID string) string {
// Replace "/directory" with "/renewalInfo/{certID}"
// For Let's Encrypt: https://acme-v02.api.letsencrypt.org/directory
// becomes: https://acme-v02.api.letsencrypt.org/renewalInfo/{certID}
baseURL := strings.TrimSuffix(directoryURL, "/directory")
return baseURL + "/renewalInfo/" + certID
}
+251
View File
@@ -0,0 +1,251 @@
package acme
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
)
// TestComputeARICertID_InvalidPEM_Input tests the ARI certificate ID computation with invalid PEM.
func TestComputeARICertID_InvalidPEM_Input(t *testing.T) {
// Test with invalid PEM data
_, err := computeARICertID("not a valid pem")
if err == nil {
t.Error("expected error for invalid PEM")
}
}
func TestConstructARIURLFallback_LetsEncrypt(t *testing.T) {
directoryURL := "https://acme-v02.api.letsencrypt.org/directory"
certID := "abc123"
url := constructARIURLFallback(directoryURL, certID)
expected := "https://acme-v02.api.letsencrypt.org/renewalInfo/abc123"
if url != expected {
t.Errorf("constructARIURLFallback: expected %s, got %s", expected, url)
}
}
func TestConstructARIURLFallback_NoDirectory(t *testing.T) {
directoryURL := "https://example.com/acme"
certID := "xyz789"
url := constructARIURLFallback(directoryURL, certID)
expected := "https://example.com/acme/renewalInfo/xyz789"
if url != expected {
t.Errorf("constructARIURLFallback: expected %s, got %s", expected, url)
}
}
// TestGetRenewalInfo_Disabled tests that ARI returns nil when disabled.
func TestGetRenewalInfo_Disabled(t *testing.T) {
config := &Config{
DirectoryURL: "https://acme.invalid/directory",
Email: "test@example.com",
ChallengeType: "http-01",
ARIEnabled: false,
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
connector := New(config, logger)
ctx := context.Background()
result, err := connector.GetRenewalInfo(ctx, "any-cert-pem")
if err != nil {
t.Fatalf("GetRenewalInfo failed: %v", err)
}
if result != nil {
t.Error("GetRenewalInfo should return nil when ARI is disabled")
}
}
// TestGetRenewalInfo_NotFound tests handling of 404 response (CA doesn't support ARI).
func TestGetRenewalInfo_NotFound(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Mock directory endpoint
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"newOrder": "/acme/new-order",
"newAccount": "/acme/new-account",
})
return
}
// All other endpoints return 404
http.Error(w, "not found", http.StatusNotFound)
}))
defer mockServer.Close()
config := &Config{
DirectoryURL: mockServer.URL + "/directory",
Email: "test@example.com",
ChallengeType: "http-01",
ARIEnabled: true,
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
connector := New(config, logger)
ctx := context.Background()
// GetRenewalInfo will fail when parsing the cert PEM, which is expected
result, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
if err == nil {
// If it doesn't fail on cert parsing, that's also okay
// The 404 handling happens after cert ID computation
if result != nil {
t.Error("GetRenewalInfo should return nil for 404 response")
}
}
}
// TestGetRenewalInfo_ServerError tests handling of server errors.
func TestGetRenewalInfo_ServerError(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Mock directory endpoint
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"newOrder": "/acme/new-order",
"newAccount": "/acme/new-account",
})
return
}
// All other endpoints return 500
http.Error(w, "internal server error", http.StatusInternalServerError)
}))
defer mockServer.Close()
config := &Config{
DirectoryURL: mockServer.URL + "/directory",
Email: "test@example.com",
ChallengeType: "http-01",
ARIEnabled: true,
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
connector := New(config, logger)
ctx := context.Background()
_, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
// Error is expected because cert parsing fails first
if err == nil {
// If we get here, the server error handling should catch it
t.Error("expected error for invalid cert or 500 response")
}
}
// TestGetRenewalInfo_InvalidPEM tests handling of invalid PEM input.
func TestGetRenewalInfo_InvalidPEM(t *testing.T) {
config := &Config{
DirectoryURL: "https://acme.invalid/directory",
Email: "test@example.com",
ChallengeType: "http-01",
ARIEnabled: true,
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
connector := New(config, logger)
ctx := context.Background()
_, err := connector.GetRenewalInfo(ctx, "invalid pem data")
if err == nil {
t.Error("GetRenewalInfo should return error for invalid PEM")
}
}
// TestGetRenewalInfo_MalformedResponse tests handling of malformed JSON response.
func TestGetRenewalInfo_MalformedResponse(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Mock directory endpoint
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"renewalInfo": "/acme/renewalInfo",
})
return
}
// Mock renewalInfo with malformed JSON
if r.URL.Path != "/directory" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"suggestedWindow": invalid json}`))
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer mockServer.Close()
config := &Config{
DirectoryURL: mockServer.URL + "/directory",
Email: "test@example.com",
ChallengeType: "http-01",
ARIEnabled: true,
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
connector := New(config, logger)
ctx := context.Background()
_, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
// Error is expected
if err == nil {
t.Error("GetRenewalInfo should return error for malformed response or invalid cert")
}
}
// TestGetRenewalInfo_MissingWindow tests handling of missing suggestedWindow.
func TestGetRenewalInfo_MissingWindow(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Mock directory endpoint
if r.URL.Path == "/directory" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"renewalInfo": "/acme/renewalInfo",
})
return
}
// Mock renewalInfo without suggestedWindow
if r.URL.Path != "/directory" && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{})
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer mockServer.Close()
config := &Config{
DirectoryURL: mockServer.URL + "/directory",
Email: "test@example.com",
ChallengeType: "http-01",
ARIEnabled: true,
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
connector := New(config, logger)
ctx := context.Background()
_, err := connector.GetRenewalInfo(ctx, "invalid-cert-pem")
// Error is expected due to invalid cert PEM
if err == nil {
t.Error("expected error for invalid cert or missing window")
}
}
+12
View File
@@ -35,6 +35,18 @@ type Connector interface {
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
// Used by the EST /cacerts endpoint. Returns empty string if not available.
GetCACertPEM(ctx context.Context) (string, error)
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9702 for a certificate.
// certPEM is the PEM-encoded certificate. Returns nil, nil if the CA does not support ARI.
GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error)
}
// RenewalInfoResult holds the ACME ARI response from a CA.
type RenewalInfoResult struct {
SuggestedWindowStart time.Time
SuggestedWindowEnd time.Time
RetryAfter time.Time
ExplanationURL string
}
// IssuanceRequest contains the parameters for issuing a new certificate.
+5
View File
@@ -735,3 +735,8 @@ func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
}
return c.caCertPEM, nil
}
// GetRenewalInfo returns nil, nil as the Local CA does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
@@ -410,6 +410,11 @@ func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
return "", fmt.Errorf("custom CA connector does not provide CA certificate access")
}
// GetRenewalInfo returns nil, nil as the custom CA connector does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// --- Helper Methods ---
// writeTempFile writes data to a temporary file and returns its path.
@@ -472,5 +472,10 @@ func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
return "", fmt.Errorf("step-ca serves its own CA certificate at /root; use step-ca's endpoint directly")
}
// GetRenewalInfo returns nil, nil as step-ca does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
@@ -0,0 +1,42 @@
package email
import (
"context"
"fmt"
)
// NotifierAdapter bridges the email.Connector (notifier.Connector interface) to the
// service.Notifier interface used by the notification registry. This adapter allows
// the existing email SMTP connector to be registered alongside Slack, Teams, etc.
type NotifierAdapter struct {
connector *Connector
}
// NewNotifierAdapter wraps an email.Connector to implement service.Notifier.
func NewNotifierAdapter(c *Connector) *NotifierAdapter {
return &NotifierAdapter{connector: c}
}
// Channel returns the notification channel identifier.
func (a *NotifierAdapter) Channel() string {
return "Email"
}
// Send delivers a notification via SMTP email.
// The recipient is the email address, subject is used as the email subject,
// and body is the email body content.
func (a *NotifierAdapter) Send(ctx context.Context, recipient string, subject string, body string) error {
if recipient == "" {
return fmt.Errorf("email: recipient address is required")
}
return a.connector.sendEmail(ctx, recipient, subject, body)
}
// SendHTML delivers an HTML email notification via SMTP.
// Used by the digest service for rich HTML digest emails.
func (a *NotifierAdapter) SendHTML(ctx context.Context, recipient string, subject string, htmlBody string) error {
if recipient == "" {
return fmt.Errorf("email: recipient address is required")
}
return a.connector.sendHTMLEmail(ctx, recipient, subject, htmlBody)
}
@@ -0,0 +1,47 @@
package email
import (
"context"
"testing"
)
func TestNotifierAdapter_Channel(t *testing.T) {
connector := New(&Config{
SMTPHost: "smtp.example.com",
SMTPPort: 587,
FromAddress: "test@example.com",
}, nil)
adapter := NewNotifierAdapter(connector)
if adapter.Channel() != "Email" {
t.Errorf("expected channel 'Email', got '%s'", adapter.Channel())
}
}
func TestNotifierAdapter_Send_EmptyRecipient(t *testing.T) {
connector := New(&Config{
SMTPHost: "smtp.example.com",
SMTPPort: 587,
FromAddress: "test@example.com",
}, nil)
adapter := NewNotifierAdapter(connector)
err := adapter.Send(context.Background(), "", "test subject", "test body")
if err == nil {
t.Fatal("expected error for empty recipient")
}
}
func TestNotifierAdapter_SendHTML_EmptyRecipient(t *testing.T) {
connector := New(&Config{
SMTPHost: "smtp.example.com",
SMTPPort: 587,
FromAddress: "test@example.com",
}, nil)
adapter := NewNotifierAdapter(connector)
err := adapter.SendHTML(context.Background(), "", "test subject", "<html>test</html>")
if err == nil {
t.Fatal("expected error for empty recipient")
}
}
@@ -195,6 +195,73 @@ func (c *Connector) sendEmail(ctx context.Context, to, subject, body string) err
return nil
}
// sendHTMLEmail sends an HTML email message using the configured SMTP server.
// Used by the digest service for rich HTML digest emails.
func (c *Connector) sendHTMLEmail(ctx context.Context, to, subject, htmlBody string) error {
addr := net.JoinHostPort(c.config.SMTPHost, strconv.Itoa(c.config.SMTPPort))
var auth smtp.Auth
if c.config.Username != "" && c.config.Password != "" {
auth = smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.SMTPHost)
}
var conn net.Conn
var err error
if c.config.UseTLS {
tlsConfig := &tls.Config{
ServerName: c.config.SMTPHost,
}
conn, err = tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect via TLS: %w", err)
}
} else {
conn, err = net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
}
defer conn.Close()
client, err := smtp.NewClient(conn, c.config.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
}
if err := client.Mail(c.config.FromAddress); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
defer wc.Close()
message := c.formatHTMLEmailMessage(c.config.FromAddress, to, subject, htmlBody)
if _, err := wc.Write(message); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err := client.Quit(); err != nil {
return fmt.Errorf("failed to quit SMTP: %w", err)
}
return nil
}
// formatEmailMessage formats an email message with standard headers.
func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte {
message := fmt.Sprintf(
@@ -208,6 +275,19 @@ func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte {
return []byte(message)
}
// formatHTMLEmailMessage formats an HTML email message with MIME headers.
func (c *Connector) formatHTMLEmailMessage(from, to, subject, htmlBody string) []byte {
message := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
from,
to,
subject,
time.Now().Format(time.RFC1123Z),
htmlBody,
)
return []byte(message)
}
// formatAlertBody formats an alert notification as email body text.
func (c *Connector) formatAlertBody(alert notifier.Alert) string {
body := fmt.Sprintf(`
+166
View File
@@ -0,0 +1,166 @@
package domain
import "testing"
func TestAgentGroup_HasDynamicCriteria_True(t *testing.T) {
tests := []AgentGroup{
{MatchOS: "linux"},
{MatchArchitecture: "amd64"},
{MatchIPCIDR: "192.168.1.0/24"},
{MatchVersion: "1.0.0"},
{MatchOS: "linux", MatchArchitecture: "amd64"},
}
for i, g := range tests {
if !g.HasDynamicCriteria() {
t.Errorf("test %d: expected HasDynamicCriteria=true, got false", i)
}
}
}
func TestAgentGroup_HasDynamicCriteria_False(t *testing.T) {
tests := []AgentGroup{
{},
{Name: "test-group"},
{Description: "some description"},
{Name: "test-group", Description: "description", Enabled: true},
}
for i, g := range tests {
if g.HasDynamicCriteria() {
t.Errorf("test %d: expected HasDynamicCriteria=false, got true", i)
}
}
}
func TestAgentGroup_MatchesAgent_AllCriteriaMatch(t *testing.T) {
group := &AgentGroup{
MatchOS: "linux",
MatchArchitecture: "amd64",
MatchVersion: "1.0.0",
MatchIPCIDR: "192.168.1.1",
}
agent := &Agent{
OS: "linux",
Architecture: "amd64",
Version: "1.0.0",
IPAddress: "192.168.1.1",
}
if !group.MatchesAgent(agent) {
t.Errorf("expected MatchesAgent=true, got false")
}
}
func TestAgentGroup_MatchesAgent_OSMismatch(t *testing.T) {
group := &AgentGroup{
MatchOS: "linux",
}
agent := &Agent{
OS: "darwin",
}
if group.MatchesAgent(agent) {
t.Errorf("expected MatchesAgent=false (OS mismatch), got true")
}
}
func TestAgentGroup_MatchesAgent_ArchMismatch(t *testing.T) {
group := &AgentGroup{
MatchArchitecture: "amd64",
}
agent := &Agent{
Architecture: "arm64",
}
if group.MatchesAgent(agent) {
t.Errorf("expected MatchesAgent=false (architecture mismatch), got true")
}
}
func TestAgentGroup_MatchesAgent_VersionMismatch(t *testing.T) {
group := &AgentGroup{
MatchVersion: "1.0.0",
}
agent := &Agent{
Version: "2.0.0",
}
if group.MatchesAgent(agent) {
t.Errorf("expected MatchesAgent=false (version mismatch), got true")
}
}
func TestAgentGroup_MatchesAgent_IPMismatch(t *testing.T) {
group := &AgentGroup{
MatchIPCIDR: "192.168.1.1",
}
agent := &Agent{
IPAddress: "192.168.1.2",
}
if group.MatchesAgent(agent) {
t.Errorf("expected MatchesAgent=false (IP mismatch), got true")
}
}
func TestAgentGroup_MatchesAgent_EmptyCriteriaMatchesAll(t *testing.T) {
group := &AgentGroup{}
agent := &Agent{
OS: "linux",
Architecture: "amd64",
Version: "1.0.0",
IPAddress: "192.168.1.1",
}
if !group.MatchesAgent(agent) {
t.Errorf("expected MatchesAgent=true (empty criteria matches all), got false")
}
}
func TestAgentGroup_MatchesAgent_PartialCriteria(t *testing.T) {
group := &AgentGroup{
MatchOS: "linux",
MatchArchitecture: "amd64",
}
agent := &Agent{
OS: "linux",
Architecture: "amd64",
Version: "1.0.0",
IPAddress: "192.168.1.1",
}
if !group.MatchesAgent(agent) {
t.Errorf("expected MatchesAgent=true (partial criteria), got false")
}
}
func TestAgentGroup_MatchesAgent_MultipleMatches(t *testing.T) {
group := &AgentGroup{
MatchOS: "linux",
MatchArchitecture: "amd64",
MatchVersion: "1.0.0",
}
// Matching agent
agent := &Agent{
OS: "linux",
Architecture: "amd64",
Version: "1.0.0",
}
if !group.MatchesAgent(agent) {
t.Errorf("expected MatchesAgent=true for matching agent, got false")
}
// Non-matching agent (version mismatch)
agent.Version = "0.9.0"
if group.MatchesAgent(agent) {
t.Errorf("expected MatchesAgent=false for non-matching agent, got true")
}
}
+35
View File
@@ -0,0 +1,35 @@
package domain
import "time"
// RenewalInfo represents ACME Renewal Information (ARI) per RFC 9702.
// It provides CA-directed renewal timing via a suggested renewal window.
type RenewalInfo struct {
// SuggestedWindowStart is the beginning of the time window during which the CA suggests renewal.
SuggestedWindowStart time.Time `json:"suggested_window_start"`
// SuggestedWindowEnd is the end of the time window during which the CA suggests renewal.
SuggestedWindowEnd time.Time `json:"suggested_window_end"`
// RetryAfter is the earliest time the client should re-poll for updated ARI.
// Zero value means no retry constraint.
RetryAfter time.Time `json:"retry_after,omitempty"`
// ExplanationURL is an optional URL with human-readable explanation for the renewal timing.
ExplanationURL string `json:"explanation_url,omitempty"`
}
// ShouldRenewNow returns true if the current time is within or past the suggested renewal window.
// This is the primary decision point: if true, renewal should proceed immediately.
func (r *RenewalInfo) ShouldRenewNow() bool {
now := time.Now()
return !now.Before(r.SuggestedWindowStart)
}
// OptimalRenewalTime returns the midpoint of the suggested renewal window,
// which is the recommended time to initiate renewal per RFC 9702.
// This can be used for scheduling if the current time is before the window.
func (r *RenewalInfo) OptimalRenewalTime() time.Time {
duration := r.SuggestedWindowEnd.Sub(r.SuggestedWindowStart)
return r.SuggestedWindowStart.Add(duration / 2)
}
+100
View File
@@ -0,0 +1,100 @@
package domain
import (
"testing"
"time"
)
func TestRenewalInfo_ShouldRenewNow_BeforeWindow(t *testing.T) {
now := time.Now()
windowStart := now.Add(1 * time.Hour)
windowEnd := now.Add(2 * time.Hour)
ri := &RenewalInfo{
SuggestedWindowStart: windowStart,
SuggestedWindowEnd: windowEnd,
}
if ri.ShouldRenewNow() {
t.Error("ShouldRenewNow should be false before window start")
}
}
func TestRenewalInfo_ShouldRenewNow_AtWindowStart(t *testing.T) {
now := time.Now()
windowStart := now
windowEnd := now.Add(1 * time.Hour)
ri := &RenewalInfo{
SuggestedWindowStart: windowStart,
SuggestedWindowEnd: windowEnd,
}
if !ri.ShouldRenewNow() {
t.Error("ShouldRenewNow should be true at window start")
}
}
func TestRenewalInfo_ShouldRenewNow_DuringWindow(t *testing.T) {
now := time.Now()
windowStart := now.Add(-30 * time.Minute)
windowEnd := now.Add(30 * time.Minute)
ri := &RenewalInfo{
SuggestedWindowStart: windowStart,
SuggestedWindowEnd: windowEnd,
}
if !ri.ShouldRenewNow() {
t.Error("ShouldRenewNow should be true during window")
}
}
func TestRenewalInfo_ShouldRenewNow_AfterWindowEnd(t *testing.T) {
now := time.Now()
windowStart := now.Add(-2 * time.Hour)
windowEnd := now.Add(-1 * time.Hour)
ri := &RenewalInfo{
SuggestedWindowStart: windowStart,
SuggestedWindowEnd: windowEnd,
}
if !ri.ShouldRenewNow() {
t.Error("ShouldRenewNow should be true after window end")
}
}
func TestRenewalInfo_OptimalRenewalTime_Midpoint(t *testing.T) {
windowStart := time.Unix(1000, 0)
windowEnd := time.Unix(3000, 0)
ri := &RenewalInfo{
SuggestedWindowStart: windowStart,
SuggestedWindowEnd: windowEnd,
}
optimal := ri.OptimalRenewalTime()
expected := time.Unix(2000, 0) // (1000 + 3000) / 2
if !optimal.Equal(expected) {
t.Errorf("OptimalRenewalTime: expected %v, got %v", expected, optimal)
}
}
func TestRenewalInfo_OptimalRenewalTime_AsymmetricWindow(t *testing.T) {
windowStart := time.Unix(1000, 0)
windowEnd := time.Unix(1300, 0) // 300 second window
ri := &RenewalInfo{
SuggestedWindowStart: windowStart,
SuggestedWindowEnd: windowEnd,
}
optimal := ri.OptimalRenewalTime()
expected := time.Unix(1150, 0) // start + 150 seconds
if !optimal.Equal(expected) {
t.Errorf("OptimalRenewalTime: expected %v, got %v", expected, optimal)
}
}
+80
View File
@@ -0,0 +1,80 @@
package domain
import "testing"
func TestCertificateStatus_Constants(t *testing.T) {
tests := map[string]CertificateStatus{
"Pending": CertificateStatusPending,
"Active": CertificateStatusActive,
"Expiring": CertificateStatusExpiring,
"Expired": CertificateStatusExpired,
"RenewalInProgress": CertificateStatusRenewalInProgress,
"Failed": CertificateStatusFailed,
"Revoked": CertificateStatusRevoked,
"Archived": CertificateStatusArchived,
}
for expected, got := range tests {
if string(got) != expected {
t.Errorf("expected %q, got %q", expected, string(got))
}
}
}
func TestDefaultAlertThresholds(t *testing.T) {
defaults := DefaultAlertThresholds()
expected := []int{30, 14, 7, 0}
if len(defaults) != len(expected) {
t.Errorf("expected %d thresholds, got %d", len(expected), len(defaults))
}
for i, v := range expected {
if i >= len(defaults) {
break
}
if defaults[i] != v {
t.Errorf("threshold[%d]: expected %d, got %d", i, v, defaults[i])
}
}
}
func TestRenewalPolicy_EffectiveAlertThresholds_Custom(t *testing.T) {
policy := &RenewalPolicy{
AlertThresholdsDays: []int{60, 30, 14, 7},
}
result := policy.EffectiveAlertThresholds()
if len(result) != 4 {
t.Errorf("expected 4 thresholds, got %d", len(result))
}
if result[0] != 60 {
t.Errorf("expected first threshold 60, got %d", result[0])
}
}
func TestRenewalPolicy_EffectiveAlertThresholds_Default(t *testing.T) {
policy := &RenewalPolicy{
AlertThresholdsDays: []int{},
}
result := policy.EffectiveAlertThresholds()
expected := DefaultAlertThresholds()
if len(result) != len(expected) {
t.Errorf("expected %d thresholds, got %d", len(expected), len(result))
}
for i, v := range expected {
if i >= len(result) {
break
}
if result[i] != v {
t.Errorf("threshold[%d]: expected %d, got %d", i, v, result[i])
}
}
}
func TestRenewalPolicy_EffectiveAlertThresholds_Nil(t *testing.T) {
policy := &RenewalPolicy{
AlertThresholdsDays: nil,
}
result := policy.EffectiveAlertThresholds()
expected := DefaultAlertThresholds()
if len(result) != len(expected) {
t.Errorf("expected %d thresholds, got %d", len(expected), len(result))
}
}
+34
View File
@@ -0,0 +1,34 @@
package domain
import "testing"
func TestJobType_Constants(t *testing.T) {
tests := map[string]JobType{
"Issuance": JobTypeIssuance,
"Renewal": JobTypeRenewal,
"Deployment": JobTypeDeployment,
"Validation": JobTypeValidation,
}
for expected, got := range tests {
if string(got) != expected {
t.Errorf("expected %q, got %q", expected, string(got))
}
}
}
func TestJobStatus_Constants(t *testing.T) {
tests := map[string]JobStatus{
"Pending": JobStatusPending,
"AwaitingCSR": JobStatusAwaitingCSR,
"AwaitingApproval": JobStatusAwaitingApproval,
"Running": JobStatusRunning,
"Completed": JobStatusCompleted,
"Failed": JobStatusFailed,
"Cancelled": JobStatusCancelled,
}
for expected, got := range tests {
if string(got) != expected {
t.Errorf("expected %q, got %q", expected, string(got))
}
}
}
+73
View File
@@ -0,0 +1,73 @@
package domain
import "testing"
func TestNotificationType_Constants(t *testing.T) {
tests := map[string]NotificationType{
"ExpirationWarning": NotificationTypeExpirationWarning,
"RenewalSuccess": NotificationTypeRenewalSuccess,
"RenewalFailure": NotificationTypeRenewalFailure,
"DeploymentSuccess": NotificationTypeDeploymentSuccess,
"DeploymentFailure": NotificationTypeDeploymentFailure,
"PolicyViolation": NotificationTypePolicyViolation,
"Revocation": NotificationTypeRevocation,
}
for expected, got := range tests {
if string(got) != expected {
t.Errorf("expected %q, got %q", expected, string(got))
}
}
}
func TestNotificationChannel_Constants(t *testing.T) {
tests := map[string]NotificationChannel{
"Email": NotificationChannelEmail,
"Webhook": NotificationChannelWebhook,
"Slack": NotificationChannelSlack,
"Teams": NotificationChannelTeams,
"PagerDuty": NotificationChannelPagerDuty,
"OpsGenie": NotificationChannelOpsGenie,
}
for expected, got := range tests {
if string(got) != expected {
t.Errorf("expected %q, got %q", expected, string(got))
}
}
}
func TestNotificationEvent_Fields(t *testing.T) {
// This test verifies the NotificationEvent struct can be instantiated
// with all expected fields.
certID := "mc-123"
errorMsg := "failed to send"
event := &NotificationEvent{
ID: "notif-1",
Type: NotificationTypeExpirationWarning,
CertificateID: &certID,
Channel: NotificationChannelSlack,
Recipient: "alerts@example.com",
Message: "Certificate expiring in 30 days",
Status: "sent",
Error: &errorMsg,
}
if event.ID != "notif-1" {
t.Errorf("expected ID 'notif-1', got %s", event.ID)
}
if event.Type != NotificationTypeExpirationWarning {
t.Errorf("expected type ExpirationWarning, got %s", string(event.Type))
}
if event.Channel != NotificationChannelSlack {
t.Errorf("expected channel Slack, got %s", string(event.Channel))
}
if event.CertificateID == nil || *event.CertificateID != "mc-123" {
t.Errorf("expected CertificateID mc-123, got %v", event.CertificateID)
}
if event.Error == nil || *event.Error != "failed to send" {
t.Errorf("expected error 'failed to send', got %v", event.Error)
}
}
+102
View File
@@ -0,0 +1,102 @@
package domain
import "testing"
func TestPolicyType_Constants(t *testing.T) {
tests := map[string]PolicyType{
"AllowedIssuers": PolicyTypeAllowedIssuers,
"AllowedDomains": PolicyTypeAllowedDomains,
"RequiredMetadata": PolicyTypeRequiredMetadata,
"AllowedEnvironments": PolicyTypeAllowedEnvironments,
"RenewalLeadTime": PolicyTypeRenewalLeadTime,
}
for expected, got := range tests {
if string(got) != expected {
t.Errorf("expected %q, got %q", expected, string(got))
}
}
}
func TestPolicySeverity_Constants(t *testing.T) {
tests := map[string]PolicySeverity{
"Warning": PolicySeverityWarning,
"Error": PolicySeverityError,
"Critical": PolicySeverityCritical,
}
for expected, got := range tests {
if string(got) != expected {
t.Errorf("expected %q, got %q", expected, string(got))
}
}
}
func TestPolicyRule_Fields(t *testing.T) {
// This test verifies the PolicyRule struct can be instantiated
// with all expected fields.
rule := &PolicyRule{
ID: "rule-1",
Name: "Allowed Issuers",
Type: PolicyTypeAllowedIssuers,
Enabled: true,
}
if rule.ID != "rule-1" {
t.Errorf("expected ID 'rule-1', got %s", rule.ID)
}
if rule.Name != "Allowed Issuers" {
t.Errorf("expected Name 'Allowed Issuers', got %s", rule.Name)
}
if rule.Type != PolicyTypeAllowedIssuers {
t.Errorf("expected Type AllowedIssuers, got %s", string(rule.Type))
}
if !rule.Enabled {
t.Errorf("expected Enabled=true, got false")
}
}
func TestPolicyViolation_Fields(t *testing.T) {
// This test verifies the PolicyViolation struct can be instantiated
// with all expected fields.
violation := &PolicyViolation{
ID: "violation-1",
CertificateID: "mc-123",
RuleID: "rule-1",
Message: "Certificate issued by unauthorized CA",
Severity: PolicySeverityCritical,
}
if violation.ID != "violation-1" {
t.Errorf("expected ID 'violation-1', got %s", violation.ID)
}
if violation.CertificateID != "mc-123" {
t.Errorf("expected CertificateID 'mc-123', got %s", violation.CertificateID)
}
if violation.RuleID != "rule-1" {
t.Errorf("expected RuleID 'rule-1', got %s", violation.RuleID)
}
if violation.Severity != PolicySeverityCritical {
t.Errorf("expected Severity Critical, got %s", string(violation.Severity))
}
}
func TestPolicySeverity_Ordering(t *testing.T) {
// This test verifies severity ordering is correct (for potential future use
// in ranking violations by impact).
severities := []PolicySeverity{
PolicySeverityWarning,
PolicySeverityError,
PolicySeverityCritical,
}
for i, severity := range severities {
if string(severity) == "" {
t.Errorf("severity %d has empty string value", i)
}
}
}
+27
View File
@@ -27,6 +27,7 @@ func RegisterTools(s *gomcp.Server, client *Client) {
registerNotificationTools(s, client)
registerStatsTools(s, client)
registerMetricsTools(s, client)
registerDigestTools(s, client)
registerHealthTools(s, client)
}
@@ -1002,6 +1003,32 @@ func registerStatsTools(s *gomcp.Server, c *Client) {
})
}
// ── Digest ──────────────────────────────────────────────────────────
func registerDigestTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_preview_digest",
Description: "Preview the scheduled certificate digest email in HTML format. Shows summary of certificate status, pending jobs, and expiring certificates.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Get("/api/v1/digest/preview", nil)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_send_digest",
Description: "Trigger immediate sending of the certificate digest email to configured recipients. If no explicit recipients are configured, sends to certificate owners.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Post("/api/v1/digest/send", nil)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
}
// ── Metrics ─────────────────────────────────────────────────────────
func registerMetricsTools(s *gomcp.Server, c *Client) {
+73 -1
View File
@@ -35,6 +35,11 @@ type NetworkScanServicer interface {
ScanAllTargets(ctx context.Context) error
}
// DigestServicer defines the interface for digest email processing used by the scheduler.
type DigestServicer interface {
ProcessDigest(ctx context.Context) error
}
// Scheduler manages background jobs and periodic tasks for the certificate control plane.
// It runs multiple concurrent loops for renewal checks, job processing, agent health checks,
// and notification processing.
@@ -44,6 +49,7 @@ type Scheduler struct {
agentService AgentServicer
notificationService NotificationServicer
networkScanService NetworkScanServicer
digestService DigestServicer
logger *slog.Logger
// Configurable tick intervals
@@ -53,6 +59,7 @@ type Scheduler struct {
notificationProcessInterval time.Duration
shortLivedExpiryCheckInterval time.Duration
networkScanInterval time.Duration
digestInterval time.Duration
// Idempotency guards: prevent duplicate execution of slow jobs
renewalCheckRunning atomic.Bool
@@ -61,6 +68,7 @@ type Scheduler struct {
notificationProcessRunning atomic.Bool
shortLivedExpiryCheckRunning atomic.Bool
networkScanRunning atomic.Bool
digestRunning atomic.Bool
// Graceful shutdown: wait for in-flight work to complete
wg sync.WaitGroup
@@ -90,9 +98,21 @@ func NewScheduler(
notificationProcessInterval: 1 * time.Minute,
shortLivedExpiryCheckInterval: 30 * time.Second,
networkScanInterval: 6 * time.Hour,
digestInterval: 24 * time.Hour,
}
}
// SetDigestService sets the digest service for the 7th scheduler loop.
// Called after construction since digest is optional.
func (s *Scheduler) SetDigestService(ds DigestServicer) {
s.digestService = ds
}
// SetDigestInterval configures the interval for digest email processing.
func (s *Scheduler) SetDigestInterval(d time.Duration) {
s.digestInterval = d
}
// SetRenewalCheckInterval configures the interval for renewal checks.
func (s *Scheduler) SetRenewalCheckInterval(d time.Duration) {
s.renewalCheckInterval = d
@@ -118,6 +138,11 @@ func (s *Scheduler) SetNetworkScanInterval(d time.Duration) {
s.networkScanInterval = d
}
// SetShortLivedExpiryCheckInterval configures the interval for short-lived certificate expiry checks.
func (s *Scheduler) SetShortLivedExpiryCheckInterval(d time.Duration) {
s.shortLivedExpiryCheckInterval = d
}
// Start initiates all background scheduler loops. It returns a channel that signals
// when the scheduler has started all loops. The scheduler runs until the context is cancelled.
func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
@@ -130,7 +155,10 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
// blocks until they've fully exited (prevents test races).
loopCount := 5
if s.networkScanService != nil {
loopCount = 6
loopCount++
}
if s.digestService != nil {
loopCount++
}
s.wg.Add(loopCount)
@@ -142,6 +170,9 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
if s.networkScanService != nil {
go func() { defer s.wg.Done(); s.networkScanLoop(ctx) }()
}
if s.digestService != nil {
go func() { defer s.wg.Done(); s.digestLoop(ctx) }()
}
// Signal that all loops are launched
close(startedChan)
@@ -445,6 +476,47 @@ func (s *Scheduler) runNetworkScan(ctx context.Context) {
}
}
// digestLoop runs every digestInterval and generates/sends certificate digest emails.
// Uses atomic.Bool to prevent duplicate execution if the previous digest is still running.
func (s *Scheduler) digestLoop(ctx context.Context) {
ticker := time.NewTicker(s.digestInterval)
defer ticker.Stop()
// Do NOT run immediately on start for digest — wait for the first tick.
// Digests are infrequent (24h default) and shouldn't fire on every restart.
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !s.digestRunning.CompareAndSwap(false, true) {
s.logger.Warn("digest processor still running, skipping tick")
continue
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer s.digestRunning.Store(false)
s.runDigest(ctx)
}()
}
}
}
// runDigest executes a single digest processing cycle with error recovery.
func (s *Scheduler) runDigest(ctx context.Context) {
opCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
if err := s.digestService.ProcessDigest(opCtx); err != nil {
s.logger.Error("digest processor failed",
"error", err,
"interval", s.digestInterval.String())
} else {
s.logger.Debug("digest processor completed")
}
}
// WaitForCompletion waits for all in-flight scheduler work to complete.
// It respects the provided timeout and returns an error if work is still in progress after timeout.
// Call this after the scheduler context has been cancelled to ensure graceful shutdown.
+280 -6
View File
@@ -11,12 +11,14 @@ import (
// mockRenewalService is a mock implementation for testing.
type mockRenewalService struct {
mu sync.Mutex
callCount int
callTimes []time.Time
slowDelay time.Duration
shouldError bool
blockCh chan struct{} // if non-nil, blocks until closed (ignores context)
mu sync.Mutex
callCount int
callTimes []time.Time
expireCallCount int
expireCallTimes []time.Time
slowDelay time.Duration
shouldError bool
blockCh chan struct{} // if non-nil, blocks until closed (ignores context)
}
func (m *mockRenewalService) CheckExpiringCertificates(ctx context.Context) error {
@@ -47,6 +49,11 @@ func (m *mockRenewalService) CheckExpiringCertificates(ctx context.Context) erro
}
func (m *mockRenewalService) ExpireShortLivedCertificates(ctx context.Context) error {
m.mu.Lock()
m.expireCallCount++
m.expireCallTimes = append(m.expireCallTimes, time.Now())
m.mu.Unlock()
if m.slowDelay > 0 {
select {
case <-time.After(m.slowDelay):
@@ -460,3 +467,270 @@ func TestSchedulerGracefulShutdown(t *testing.T) {
}
jobMock.mu.Unlock()
}
// TestSchedulerRenewalLoopCallsService verifies that the renewal loop executes the renewal service.
func TestSchedulerRenewalLoopCallsService(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{}
jobMock := &mockJobService{}
agentMock := &mockAgentService{}
notificationMock := &mockNotificationService{}
networkMock := &mockNetworkScanService{}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
sched.SetRenewalCheckInterval(50 * time.Millisecond)
sched.SetJobProcessorInterval(10 * time.Second)
sched.SetAgentHealthCheckInterval(10 * time.Second)
sched.SetNotificationProcessInterval(10 * time.Second)
sched.SetNetworkScanInterval(10 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startedChan := sched.Start(ctx)
<-startedChan
time.Sleep(200 * time.Millisecond)
cancel()
sched.WaitForCompletion(2 * time.Second)
renewalMock.mu.Lock()
count := renewalMock.callCount
renewalMock.mu.Unlock()
if count < 1 {
t.Fatalf("expected renewal service to be called at least once, got %d", count)
}
t.Logf("renewal loop called %d times", count)
}
// TestSchedulerJobProcessorLoopCallsService verifies that the job processor loop executes the job service.
func TestSchedulerJobProcessorLoopCallsService(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{}
jobMock := &mockJobService{}
agentMock := &mockAgentService{}
notificationMock := &mockNotificationService{}
networkMock := &mockNetworkScanService{}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
sched.SetRenewalCheckInterval(10 * time.Second)
sched.SetJobProcessorInterval(50 * time.Millisecond)
sched.SetAgentHealthCheckInterval(10 * time.Second)
sched.SetNotificationProcessInterval(10 * time.Second)
sched.SetNetworkScanInterval(10 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startedChan := sched.Start(ctx)
<-startedChan
time.Sleep(200 * time.Millisecond)
cancel()
sched.WaitForCompletion(2 * time.Second)
jobMock.mu.Lock()
count := jobMock.callCount
jobMock.mu.Unlock()
if count < 1 {
t.Fatalf("expected job service to be called at least once, got %d", count)
}
t.Logf("job processor loop called %d times", count)
}
// TestSchedulerAgentHealthCheckLoopCallsService verifies that the agent health check loop executes the agent service.
func TestSchedulerAgentHealthCheckLoopCallsService(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{}
jobMock := &mockJobService{}
agentMock := &mockAgentService{}
notificationMock := &mockNotificationService{}
networkMock := &mockNetworkScanService{}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
sched.SetRenewalCheckInterval(10 * time.Second)
sched.SetJobProcessorInterval(10 * time.Second)
sched.SetAgentHealthCheckInterval(50 * time.Millisecond)
sched.SetNotificationProcessInterval(10 * time.Second)
sched.SetNetworkScanInterval(10 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startedChan := sched.Start(ctx)
<-startedChan
time.Sleep(200 * time.Millisecond)
cancel()
sched.WaitForCompletion(2 * time.Second)
agentMock.mu.Lock()
count := agentMock.callCount
agentMock.mu.Unlock()
if count < 1 {
t.Fatalf("expected agent service to be called at least once, got %d", count)
}
t.Logf("agent health check loop called %d times", count)
}
// TestSchedulerNotificationLoopCallsService verifies that the notification loop executes the notification service.
func TestSchedulerNotificationLoopCallsService(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{}
jobMock := &mockJobService{}
agentMock := &mockAgentService{}
notificationMock := &mockNotificationService{}
networkMock := &mockNetworkScanService{}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
sched.SetRenewalCheckInterval(10 * time.Second)
sched.SetJobProcessorInterval(10 * time.Second)
sched.SetAgentHealthCheckInterval(10 * time.Second)
sched.SetNotificationProcessInterval(50 * time.Millisecond)
sched.SetNetworkScanInterval(10 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startedChan := sched.Start(ctx)
<-startedChan
time.Sleep(200 * time.Millisecond)
cancel()
sched.WaitForCompletion(2 * time.Second)
notificationMock.mu.Lock()
count := notificationMock.callCount
notificationMock.mu.Unlock()
if count < 1 {
t.Fatalf("expected notification service to be called at least once, got %d", count)
}
t.Logf("notification loop called %d times", count)
}
// TestSchedulerNetworkScanLoopCallsService verifies that the network scan loop executes the network scan service.
func TestSchedulerNetworkScanLoopCallsService(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{}
jobMock := &mockJobService{}
agentMock := &mockAgentService{}
notificationMock := &mockNotificationService{}
networkMock := &mockNetworkScanService{}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
sched.SetRenewalCheckInterval(10 * time.Second)
sched.SetJobProcessorInterval(10 * time.Second)
sched.SetAgentHealthCheckInterval(10 * time.Second)
sched.SetNotificationProcessInterval(10 * time.Second)
sched.SetNetworkScanInterval(50 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startedChan := sched.Start(ctx)
<-startedChan
time.Sleep(200 * time.Millisecond)
cancel()
sched.WaitForCompletion(2 * time.Second)
networkMock.mu.Lock()
count := networkMock.callCount
networkMock.mu.Unlock()
if count < 1 {
t.Fatalf("expected network scan service to be called at least once, got %d", count)
}
t.Logf("network scan loop called %d times", count)
}
// TestSchedulerShortLivedExpiryLoopCallsService verifies that the short-lived expiry loop executes the renewal service.
func TestSchedulerShortLivedExpiryLoopCallsService(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{}
jobMock := &mockJobService{}
agentMock := &mockAgentService{}
notificationMock := &mockNotificationService{}
networkMock := &mockNetworkScanService{}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
sched.SetRenewalCheckInterval(10 * time.Second)
sched.SetJobProcessorInterval(10 * time.Second)
sched.SetAgentHealthCheckInterval(10 * time.Second)
sched.SetNotificationProcessInterval(10 * time.Second)
sched.SetNetworkScanInterval(10 * time.Second)
sched.SetShortLivedExpiryCheckInterval(50 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startedChan := sched.Start(ctx)
<-startedChan
time.Sleep(200 * time.Millisecond)
cancel()
sched.WaitForCompletion(2 * time.Second)
renewalMock.mu.Lock()
count := renewalMock.expireCallCount
renewalMock.mu.Unlock()
if count < 1 {
t.Fatalf("expected short-lived expiry to be called at least once, got %d", count)
}
t.Logf("short-lived expiry loop called %d times", count)
}
// TestSchedulerLoopErrorRecovery verifies that scheduler loops continue executing after errors.
func TestSchedulerLoopErrorRecovery(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{shouldError: true}
jobMock := &mockJobService{shouldError: true}
agentMock := &mockAgentService{shouldError: true}
notificationMock := &mockNotificationService{shouldError: true}
networkMock := &mockNetworkScanService{shouldError: true}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
sched.SetRenewalCheckInterval(50 * time.Millisecond)
sched.SetJobProcessorInterval(50 * time.Millisecond)
sched.SetAgentHealthCheckInterval(50 * time.Millisecond)
sched.SetNotificationProcessInterval(50 * time.Millisecond)
sched.SetNetworkScanInterval(50 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
startedChan := sched.Start(ctx)
<-startedChan
time.Sleep(300 * time.Millisecond)
cancel()
err := sched.WaitForCompletion(2 * time.Second)
if err != nil {
t.Fatalf("WaitForCompletion should not error even with service errors: %v", err)
}
renewalMock.mu.Lock()
renewalCount := renewalMock.callCount
renewalMock.mu.Unlock()
if renewalCount < 2 {
t.Fatalf("expected renewal service to be called at least twice (error recovery), got %d", renewalCount)
}
jobMock.mu.Lock()
jobCount := jobMock.callCount
jobMock.mu.Unlock()
if jobCount < 2 {
t.Fatalf("expected job service to be called at least twice (error recovery), got %d", jobCount)
}
t.Logf("scheduler recovered from errors: renewal %d calls, job %d calls", renewalCount, jobCount)
}
// TestSchedulerLoopContextCancellation verifies graceful shutdown when context is cancelled immediately.
func TestSchedulerLoopContextCancellation(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
renewalMock := &mockRenewalService{}
jobMock := &mockJobService{}
agentMock := &mockAgentService{}
notificationMock := &mockNotificationService{}
networkMock := &mockNetworkScanService{}
sched := NewScheduler(renewalMock, jobMock, agentMock, notificationMock, networkMock, logger)
sched.SetRenewalCheckInterval(50 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
startedChan := sched.Start(ctx)
<-startedChan
cancel()
err := sched.WaitForCompletion(2 * time.Second)
if err != nil {
t.Fatalf("WaitForCompletion should succeed even with immediate cancellation: %v", err)
}
t.Logf("scheduler shut down gracefully on context cancellation")
}
+468
View File
@@ -0,0 +1,468 @@
package service
import (
"context"
"fmt"
"sync"
"testing"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// TestConcurrentCertificateList tests that 10 goroutines can safely list certificates simultaneously
func TestConcurrentCertificateList(t *testing.T) {
mockCertRepo := newMockCertificateRepository()
// Add test certificates
for i := 0; i < 20; i++ {
mockCertRepo.AddCert(&domain.ManagedCertificate{
ID: fmt.Sprintf("mc-test-%d", i),
CommonName: fmt.Sprintf("test-%d.example.com", i),
})
}
certSvc := NewCertificateService(mockCertRepo, nil, nil)
var wg sync.WaitGroup
const goroutines = 10
errChan := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := context.Background()
certs, total, err := certSvc.List(ctx, &repository.CertificateFilter{})
if err != nil {
errChan <- fmt.Errorf("goroutine %d: failed to list: %w", idx, err)
return
}
if certs == nil {
errChan <- fmt.Errorf("goroutine %d: returned nil certs slice", idx)
return
}
if total != 20 {
errChan <- fmt.Errorf("goroutine %d: expected 20 certs, got %d", idx, total)
return
}
}(i)
}
wg.Wait()
close(errChan)
// Verify no errors occurred
for err := range errChan {
t.Errorf("concurrent list error: %v", err)
}
}
// TestConcurrentJobStatusUpdates tests that 10 goroutines can safely update different jobs simultaneously
func TestConcurrentJobStatusUpdates(t *testing.T) {
mockJobRepo := newMockJobRepository()
// Create 10 jobs
for i := 0; i < 10; i++ {
job := &domain.Job{
ID: fmt.Sprintf("job-%d", i),
Status: domain.JobStatusPending,
}
mockJobRepo.AddJob(job)
}
var wg sync.WaitGroup
const goroutines = 10
errChan := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := context.Background()
jobID := fmt.Sprintf("job-%d", idx)
newStatus := domain.JobStatusRunning
err := mockJobRepo.UpdateStatus(ctx, jobID, newStatus, "")
if err != nil {
errChan <- fmt.Errorf("goroutine %d: failed to update job %s: %w", idx, jobID, err)
return
}
// Verify the update
job, err := mockJobRepo.Get(ctx, jobID)
if err != nil {
errChan <- fmt.Errorf("goroutine %d: failed to get job %s: %w", idx, jobID, err)
return
}
if job.Status != newStatus {
errChan <- fmt.Errorf("goroutine %d: job %s status is %s, expected %s", idx, jobID, job.Status, newStatus)
return
}
}(i)
}
wg.Wait()
close(errChan)
// Verify no errors occurred
for err := range errChan {
t.Errorf("concurrent job update error: %v", err)
}
}
// TestConcurrentAgentHeartbeats tests that 10 goroutines can safely send heartbeats for different agents simultaneously
func TestConcurrentAgentHeartbeats(t *testing.T) {
mockAgentRepo := newMockAgentRepository()
// Create 10 agents
for i := 0; i < 10; i++ {
agent := &domain.Agent{
ID: fmt.Sprintf("agent-%d", i),
Name: fmt.Sprintf("agent-%d", i),
Hostname: fmt.Sprintf("host-%d", i),
}
mockAgentRepo.AddAgent(agent)
}
agentSvc := NewAgentService(
mockAgentRepo,
nil, // certRepo
nil, // jobRepo
nil, // targetRepo
nil, // auditService
make(map[string]IssuerConnector),
nil, // renewalService
)
var wg sync.WaitGroup
const goroutines = 10
errChan := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := context.Background()
agentID := fmt.Sprintf("agent-%d", idx)
metadata := &domain.AgentMetadata{
OS: "linux",
Architecture: "x86_64",
}
err := agentSvc.HeartbeatWithContext(ctx, agentID, metadata)
if err != nil {
errChan <- fmt.Errorf("goroutine %d: failed heartbeat for agent %s: %w", idx, agentID, err)
return
}
// Verify the heartbeat was recorded
agent, err := mockAgentRepo.Get(ctx, agentID)
if err != nil {
errChan <- fmt.Errorf("goroutine %d: failed to get agent %s: %w", idx, agentID, err)
return
}
if agent.LastHeartbeatAt == nil {
errChan <- fmt.Errorf("goroutine %d: agent %s has no heartbeat", idx, agentID)
return
}
}(i)
}
wg.Wait()
close(errChan)
// Verify no errors occurred
for err := range errChan {
t.Errorf("concurrent heartbeat error: %v", err)
}
}
// TestConcurrentTargetCRUD tests concurrent create/list/delete operations on targets
func TestConcurrentTargetCRUD(t *testing.T) {
mockTargetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
targetSvc := NewTargetService(mockTargetRepo, nil)
var mu sync.Mutex
createdTargets := make([]string, 0)
var wg sync.WaitGroup
// Phase 1: Create 5 targets in parallel
for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := context.Background()
target := &domain.DeploymentTarget{
ID: fmt.Sprintf("target-create-%d", idx),
Name: fmt.Sprintf("target-%d", idx),
Type: domain.TargetTypeNGINX,
}
err := targetSvc.Create(ctx, target, "test-user")
if err != nil {
t.Errorf("concurrent create error: %v", err)
return
}
mu.Lock()
createdTargets = append(createdTargets, target.ID)
mu.Unlock()
}(i)
}
wg.Wait()
// Phase 2: List targets in parallel
for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := context.Background()
_, _, err := targetSvc.List(ctx, 1, 50)
if err != nil {
t.Errorf("goroutine %d: concurrent list error: %v", idx, err)
return
}
}(i)
}
wg.Wait()
// Phase 3: Delete created targets in parallel
for _, targetID := range createdTargets {
targetIDCopy := targetID // Capture for closure
wg.Add(1)
go func() {
defer wg.Done()
ctx := context.Background()
err := targetSvc.Delete(ctx, targetIDCopy, "test-user")
if err != nil {
t.Errorf("concurrent delete error: %v", err)
return
}
}()
}
wg.Wait()
// Verify all targets were deleted
targets, err := mockTargetRepo.List(context.Background())
if err != nil {
t.Fatalf("failed to list targets: %v", err)
}
if len(targets) != 0 {
t.Errorf("expected 0 targets after deletion, got %d", len(targets))
}
}
// TestConcurrentNotificationProcessing tests concurrent notification sends
func TestConcurrentNotificationProcessing(t *testing.T) {
mockNotifRepo := newMockNotificationRepository()
mockNotifier := newMockNotifier()
var wg sync.WaitGroup
const goroutines = 10
errChan := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := context.Background()
notif := &domain.NotificationEvent{
ID: fmt.Sprintf("notif-%d", idx),
Type: domain.NotificationTypeExpirationWarning,
Recipient: fmt.Sprintf("user-%d@example.com", idx),
Message: fmt.Sprintf("Notification message %d", idx),
Status: "pending",
}
err := mockNotifRepo.Create(ctx, notif)
if err != nil {
errChan <- fmt.Errorf("goroutine %d: failed to create notification: %w", idx, err)
return
}
// Simulate sending notification
err = mockNotifier.Send(ctx, notif.Recipient, "Certificate Expiring", notif.Message)
if err != nil {
errChan <- fmt.Errorf("goroutine %d: failed to send notification: %w", idx, err)
return
}
}(i)
}
wg.Wait()
close(errChan)
// Verify no errors occurred
for err := range errChan {
t.Errorf("concurrent notification error: %v", err)
}
// Verify all notifications were processed
if len(mockNotifRepo.Notifications) != goroutines {
t.Errorf("expected %d notifications, got %d", goroutines, len(mockNotifRepo.Notifications))
}
if len(mockNotifier.messages) != goroutines {
t.Errorf("expected %d sent messages, got %d", goroutines, len(mockNotifier.messages))
}
}
// TestConcurrentAuditRecording tests concurrent audit event recording
func TestConcurrentAuditRecording(t *testing.T) {
mockAuditRepo := newMockAuditRepository()
auditSvc := &AuditService{auditRepo: mockAuditRepo}
var wg sync.WaitGroup
const goroutines = 10
errChan := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := context.Background()
actor := fmt.Sprintf("user-%d", idx)
eventType := "create_certificate"
resourceID := fmt.Sprintf("cert-%d", idx)
err := auditSvc.RecordEvent(
ctx,
actor,
domain.ActorTypeUser,
eventType,
"certificate",
resourceID,
map[string]interface{}{"index": idx},
)
if err != nil {
errChan <- fmt.Errorf("goroutine %d: failed to record audit event: %w", idx, err)
return
}
}(i)
}
wg.Wait()
close(errChan)
// Verify no errors occurred
for err := range errChan {
t.Errorf("concurrent audit error: %v", err)
}
// Verify all audit events were recorded
if len(mockAuditRepo.Events) != goroutines {
t.Errorf("expected %d audit events, got %d", goroutines, len(mockAuditRepo.Events))
}
}
// TestConcurrentMixedOperations tests mixed concurrent operations on multiple services
func TestConcurrentMixedOperations(t *testing.T) {
// Setup repositories
mockCertRepo := newMockCertificateRepository()
mockJobRepo := newMockJobRepository()
mockAuditRepo := newMockAuditRepository()
mockTargetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
// Add initial test data
for i := 0; i < 5; i++ {
mockCertRepo.AddCert(&domain.ManagedCertificate{
ID: fmt.Sprintf("mc-mixed-%d", i),
CommonName: fmt.Sprintf("mixed-%d.example.com", i),
})
mockJobRepo.AddJob(&domain.Job{
ID: fmt.Sprintf("job-mixed-%d", i),
Status: domain.JobStatusPending,
})
}
// Setup services
auditSvc := &AuditService{auditRepo: mockAuditRepo}
certSvc := NewCertificateService(mockCertRepo, nil, auditSvc)
targetSvc := NewTargetService(mockTargetRepo, auditSvc)
var wg sync.WaitGroup
errChan := make(chan error, 30)
// Launch mixed concurrent operations
for i := 0; i < 10; i++ {
// Certificate operations
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := context.Background()
_, _, err := certSvc.List(ctx, &repository.CertificateFilter{})
if err != nil {
errChan <- fmt.Errorf("cert list %d: %w", idx, err)
}
}(i)
// Target operations
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := context.Background()
_, _, err := targetSvc.List(ctx, 1, 50)
if err != nil {
errChan <- fmt.Errorf("target list %d: %w", idx, err)
}
}(i)
// Audit operations
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := context.Background()
err := auditSvc.RecordEvent(
ctx,
fmt.Sprintf("user-%d", idx),
domain.ActorTypeUser,
"test_event",
"test",
fmt.Sprintf("test-%d", idx),
nil,
)
if err != nil {
errChan <- fmt.Errorf("audit record %d: %w", idx, err)
}
}(i)
}
wg.Wait()
close(errChan)
// Verify no errors occurred
errorCount := 0
for err := range errChan {
t.Logf("concurrent mixed error: %v", err)
errorCount++
}
if errorCount > 0 {
t.Errorf("had %d concurrent operation errors", errorCount)
}
}
+234
View File
@@ -0,0 +1,234 @@
package service
import (
"context"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// TestCertificateService_ListWithCancelledContext verifies that List respects a cancelled context
func TestCertificateService_ListWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
mockCertRepo := newMockCertificateRepository()
certSvc := NewCertificateService(mockCertRepo, nil, nil)
_, _, err := certSvc.List(ctx, &repository.CertificateFilter{})
// The service should propagate context cancellation errors
// even though our mock may not check context, we verify the call goes through
// and the context error becomes part of the error chain
if err == nil || ctx.Err() == context.Canceled {
// Either the service respects context and returns an error,
// or the context was cancelled. Both are valid findings.
return
}
t.Logf("List with cancelled context returned: %v", err)
}
// TestCertificateService_GetWithCancelledContext verifies that Get respects a cancelled context
func TestCertificateService_GetWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
mockCertRepo := newMockCertificateRepository()
mockCertRepo.AddCert(&domain.ManagedCertificate{ID: "mc-test-1", CommonName: "test.example.com"})
certSvc := NewCertificateService(mockCertRepo, nil, nil)
_, err := certSvc.Get(ctx, "mc-test-1")
// Service should handle cancelled context
if err == nil || ctx.Err() == context.Canceled {
return
}
t.Logf("Get with cancelled context returned: %v", err)
}
// TestRenewalService_ProcessWithCancelledContext verifies that renewal processing respects a cancelled context
func TestRenewalService_ProcessWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
mockCertRepo := newMockCertificateRepository()
mockJobRepo := newMockJobRepository()
mockPolicyRepo := newMockRenewalPolicyRepository()
mockProfileRepo := &mockCertificateProfileRepository{
Profiles: make(map[string]*domain.CertificateProfile),
}
mockAuditSvc := &AuditService{auditRepo: newMockAuditRepository()}
mockNotifSvc := &NotificationService{
notifRepo: newMockNotificationRepository(),
ownerRepo: nil,
notifierRegistry: make(map[string]Notifier),
}
renewalSvc := NewRenewalService(
mockCertRepo,
mockJobRepo,
mockPolicyRepo,
mockProfileRepo,
mockAuditSvc,
mockNotifSvc,
make(map[string]IssuerConnector),
"agent",
)
// Attempt to check expiring certificates with cancelled context
err := renewalSvc.CheckExpiringCertificates(ctx)
// Should handle cancelled context gracefully
if err == nil || ctx.Err() == context.Canceled {
return
}
t.Logf("CheckExpiringCertificates with cancelled context returned: %v", err)
}
// mockCertificateProfileRepository is a mock for testing
type mockCertificateProfileRepository struct {
Profiles map[string]*domain.CertificateProfile
GetErr error
ListErr error
}
func (m *mockCertificateProfileRepository) List(ctx context.Context) ([]*domain.CertificateProfile, error) {
if m.ListErr != nil {
return nil, m.ListErr
}
var profiles []*domain.CertificateProfile
for _, p := range m.Profiles {
profiles = append(profiles, p)
}
return profiles, nil
}
func (m *mockCertificateProfileRepository) Get(ctx context.Context, id string) (*domain.CertificateProfile, error) {
if m.GetErr != nil {
return nil, m.GetErr
}
profile, ok := m.Profiles[id]
if !ok {
return nil, errNotFound
}
return profile, nil
}
func (m *mockCertificateProfileRepository) Create(ctx context.Context, profile *domain.CertificateProfile) error {
m.Profiles[profile.ID] = profile
return nil
}
func (m *mockCertificateProfileRepository) Update(ctx context.Context, profile *domain.CertificateProfile) error {
m.Profiles[profile.ID] = profile
return nil
}
func (m *mockCertificateProfileRepository) Delete(ctx context.Context, id string) error {
delete(m.Profiles, id)
return nil
}
// TestTargetService_ListWithCancelledContext verifies that target listing respects a cancelled context
func TestTargetService_ListWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
mockTargetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
targetSvc := NewTargetService(mockTargetRepo, nil)
_, _, err := targetSvc.List(ctx, 1, 50)
// Service should handle cancelled context
if err == nil || ctx.Err() == context.Canceled {
return
}
t.Logf("TargetService.List with cancelled context returned: %v", err)
}
// TestAgentService_HeartbeatWithCancelledContext verifies that heartbeat respects a cancelled context
func TestAgentService_HeartbeatWithCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
mockAgentRepo := newMockAgentRepository()
mockAgentRepo.AddAgent(&domain.Agent{
ID: "agent-1",
Name: "test-agent",
Hostname: "localhost",
})
agentSvc := NewAgentService(
mockAgentRepo,
nil, // certRepo
nil, // jobRepo
nil, // targetRepo
nil, // auditService
make(map[string]IssuerConnector),
nil, // renewalService
)
err := agentSvc.HeartbeatWithContext(ctx, "agent-1", &domain.AgentMetadata{})
// Service should handle cancelled context
if err == nil || ctx.Err() == context.Canceled {
return
}
t.Logf("HeartbeatWithContext with cancelled context returned: %v", err)
}
// Test with timeout context (should trigger deadline exceeded)
func TestCertificateService_ListWithDeadlineExceeded(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 0) // Immediate timeout
defer cancel()
mockCertRepo := newMockCertificateRepository()
certSvc := NewCertificateService(mockCertRepo, nil, nil)
time.Sleep(10 * time.Millisecond) // Ensure deadline is exceeded
_, _, err := certSvc.List(ctx, &repository.CertificateFilter{})
// Should handle deadline exceeded gracefully
if err == nil || ctx.Err() == context.DeadlineExceeded {
return
}
t.Logf("List with deadline exceeded returned: %v", err)
}
// Test with timeout context on agent heartbeat
func TestAgentService_HeartbeatWithDeadlineExceeded(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 0) // Immediate timeout
defer cancel()
mockAgentRepo := newMockAgentRepository()
mockAgentRepo.AddAgent(&domain.Agent{
ID: "agent-1",
Name: "test-agent",
Hostname: "localhost",
})
agentSvc := NewAgentService(
mockAgentRepo,
nil, // certRepo
nil, // jobRepo
nil, // targetRepo
nil, // auditService
make(map[string]IssuerConnector),
nil, // renewalService
)
time.Sleep(10 * time.Millisecond) // Ensure deadline is exceeded
err := agentSvc.HeartbeatWithContext(ctx, "agent-1", &domain.AgentMetadata{})
// Service should handle deadline exceeded
if err == nil || ctx.Err() == context.DeadlineExceeded {
return
}
t.Logf("HeartbeatWithContext with deadline exceeded returned: %v", err)
}
+462
View File
@@ -0,0 +1,462 @@
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// NOTE: generateTestCSR(t, keyType, keySize) is defined in crypto_validation_test.go
// Use it as: generateTestCSR(t, "ECDSA", 256)
// newTestRenewalServiceForCSR creates a RenewalService with mocks suitable for CSR renewal testing.
func newTestRenewalServiceForCSR(issuerErr error) *RenewalService {
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
profileRepo := newMockProfileRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
notifier := newMockNotifier()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{
"Email": notifier,
})
issuerConnector := &mockIssuerConnector{Err: issuerErr}
issuerRegistry := map[string]IssuerConnector{
"iss-local": issuerConnector,
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, profileRepo, auditSvc, notifSvc, issuerRegistry, "agent")
return svc
}
// TestCompleteAgentCSRRenewal_Success tests the happy path: valid CSR, issuer signs, cert stored, deployment jobs created.
func TestCompleteAgentCSRRenewal_Success(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-001",
Name: "Test Certificate",
CommonName: "example.com",
SANs: []string{"www.example.com"},
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
TargetIDs: []string{"t-nginx-1"},
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-csr-001",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err != nil {
t.Fatalf("CompleteAgentCSRRenewal failed: %v", err)
}
// Verify job was completed
updatedJob, err := jobRepo.Get(ctx, job.ID)
if err != nil {
t.Fatalf("failed to get job after renewal: %v", err)
}
if updatedJob.Status != domain.JobStatusCompleted {
t.Errorf("expected job status Completed, got %s", updatedJob.Status)
}
// Verify certificate version was created
versions, err := certRepo.ListVersions(ctx, cert.ID)
if err != nil {
t.Fatalf("failed to list versions: %v", err)
}
if len(versions) != 1 {
t.Errorf("expected 1 version, got %d", len(versions))
}
// Verify version fields
version := versions[0]
if version.SerialNumber != "test-serial-123" {
t.Errorf("expected serial 'test-serial-123', got %s", version.SerialNumber)
}
if version.CSRPEM != csrPEM {
t.Errorf("expected CSR PEM to be stored as-is (agent mode), got mismatch")
}
if version.PEMChain == "" {
t.Errorf("expected PEMChain to be populated")
}
// Verify certificate was updated
updatedCert, err := certRepo.Get(ctx, cert.ID)
if err != nil {
t.Fatalf("failed to get cert after renewal: %v", err)
}
if updatedCert.Status != domain.CertificateStatusActive {
t.Errorf("expected cert status Active, got %s", updatedCert.Status)
}
if updatedCert.LastRenewalAt == nil {
t.Errorf("expected LastRenewalAt to be set")
}
// Verify deployment jobs were created
deploymentJobs := 0
for _, j := range jobRepo.Jobs {
if j.Type == domain.JobTypeDeployment && j.CertificateID == cert.ID {
deploymentJobs++
}
}
if deploymentJobs != 1 {
t.Errorf("expected 1 deployment job, got %d", deploymentJobs)
}
}
// TestCompleteAgentCSRRenewal_JobNotFound tests that the method handles a missing job gracefully.
func TestCompleteAgentCSRRenewal_JobNotFound(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-not-found",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
// Job not added to repo — simulates "not found" on status update
job := &domain.Job{
ID: "job-nonexistent",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
CreatedAt: time.Now(),
}
csrPEM := generateTestCSR(t, "ECDSA", 256)
// Call will pass CSR validation but fail when updating job status to Running
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error for missing job, got nil")
}
}
// TestCompleteAgentCSRRenewal_JobNotAwaitingCSR tests that the method processes regardless of job state
// (the method doesn't check job.Status — it trusts the caller).
func TestCompleteAgentCSRRenewal_JobNotAwaitingCSR(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-wrong-state",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-running",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusRunning, // Wrong state — method doesn't check
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
// The method doesn't validate job state, so it should still process
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
// Depending on mock behavior, this may succeed or fail — the point is no panic
_ = err
}
// TestCompleteAgentCSRRenewal_InvalidCSR tests that invalid CSR PEM causes failure.
func TestCompleteAgentCSRRenewal_InvalidCSR(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-invalid-csr",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-invalid-csr",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
invalidCSR := "not a pem certificate request at all"
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, invalidCSR)
if err == nil {
t.Errorf("expected error for invalid CSR, got nil")
}
// Verify job was marked as failed
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusFailed {
t.Errorf("expected job status Failed after CSR validation error, got %s", updatedJob.Status)
}
if updatedJob.LastError == nil || *updatedJob.LastError == "" {
t.Errorf("expected error message stored in job, got none")
}
}
// TestCompleteAgentCSRRenewal_IssuerError tests that issuer connector failure is handled.
func TestCompleteAgentCSRRenewal_IssuerError(t *testing.T) {
ctx := context.Background()
issuerErr := errors.New("issuer signing failed")
svc := newTestRenewalServiceForCSR(issuerErr)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-issuer-error",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-issuer-error",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error from issuer failure, got nil")
}
// Verify job was marked as failed
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %s", updatedJob.Status)
}
// Verify no version was created
versions, _ := certRepo.ListVersions(ctx, cert.ID)
if len(versions) > 0 {
t.Errorf("expected no version created after issuer failure, got %d", len(versions))
}
}
// TestCompleteAgentCSRRenewal_StoreVersionError tests that version storage failure is handled.
func TestCompleteAgentCSRRenewal_StoreVersionError(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
certRepo.CreateVersionErr = errors.New("version storage failed")
jobRepo := svc.jobRepo.(*mockJobRepo)
cert := &domain.ManagedCertificate{
ID: "mc-test-store-error",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-store-error",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error from version storage failure, got nil")
}
// Verify job was marked as failed
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %s", updatedJob.Status)
}
// Verify no version was actually stored
versions, _ := certRepo.ListVersions(ctx, cert.ID)
if len(versions) > 0 {
t.Errorf("expected no version stored after storage error, got %d", len(versions))
}
}
// TestCompleteAgentCSRRenewal_CertNotFound tests that missing issuer connector is handled.
func TestCompleteAgentCSRRenewal_CertNotFound(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
jobRepo := svc.jobRepo.(*mockJobRepo)
job := &domain.Job{
ID: "job-cert-not-found",
CertificateID: "mc-nonexistent",
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
cert := &domain.ManagedCertificate{
ID: "mc-cert-not-found",
CommonName: "example.com",
IssuerID: "iss-nonexistent", // Not in registry
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err == nil {
t.Errorf("expected error for missing issuer, got nil")
}
if !contains(err.Error(), "issuer connector not found") {
t.Errorf("expected 'issuer connector not found' error, got: %v", err)
}
}
// TestCompleteAgentCSRRenewal_EKUFromProfile tests that EKUs are resolved from profile and passed to issuer.
func TestCompleteAgentCSRRenewal_EKUFromProfile(t *testing.T) {
ctx := context.Background()
svc := newTestRenewalServiceForCSR(nil)
certRepo := svc.certRepo.(*mockCertRepo)
jobRepo := svc.jobRepo.(*mockJobRepo)
profileRepo := svc.profileRepo.(*mockProfileRepo)
profile := &domain.CertificateProfile{
ID: "prof-smime",
Name: "S/MIME",
MaxTTLSeconds: 31536000, // 365 days
AllowedEKUs: []string{"emailProtection", "clientAuth"},
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
profileRepo.AddProfile(profile)
cert := &domain.ManagedCertificate{
ID: "mc-test-eku",
Name: "S/MIME Certificate",
CommonName: "user@example.com",
SANs: []string{"user@example.com"},
IssuerID: "iss-local",
CertificateProfileID: "prof-smime",
Status: domain.CertificateStatusRenewalInProgress,
ExpiresAt: time.Now().AddDate(1, 0, 0),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
job := &domain.Job{
ID: "job-eku",
CertificateID: cert.ID,
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingCSR,
MaxAttempts: 3,
ScheduledAt: time.Now(),
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := svc.CompleteAgentCSRRenewal(ctx, job, cert, csrPEM)
if err != nil {
t.Fatalf("CompleteAgentCSRRenewal failed: %v", err)
}
// Verify job was completed — profile lookup + EKU resolution worked
updatedJob, _ := jobRepo.Get(ctx, job.ID)
if updatedJob.Status != domain.JobStatusCompleted {
t.Errorf("expected job status Completed, got %s", updatedJob.Status)
}
}
+792
View File
@@ -0,0 +1,792 @@
package service
import (
"context"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// newTestDeploymentService creates a test deployment service with all necessary mocks.
func newTestDeploymentService() (*DeploymentService, *mockJobRepo, *mockTargetRepo, *mockAgentRepo, *mockCertRepo, *mockAuditRepo, *mockNotifier) {
jobRepo := newMockJobRepository()
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
agentRepo := newMockAgentRepository()
certRepo := newMockCertificateRepository()
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
notifRepo := newMockNotificationRepository()
notifier := newMockNotifier()
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{"Email": notifier})
svc := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditSvc, notifSvc)
return svc, jobRepo, targetRepo, agentRepo, certRepo, auditRepo, notifier
}
// TestDeploymentService_CreateDeploymentJobs_Success tests successful creation of deployment jobs.
func TestDeploymentService_CreateDeploymentJobs_Success(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, _, _, _, _ := newTestDeploymentService()
// Add two targets
target1 := &domain.DeploymentTarget{
ID: "tgt-nginx-1",
Name: "NGINX Server 1",
Type: domain.TargetTypeNGINX,
AgentID: "agent-1",
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
target2 := &domain.DeploymentTarget{
ID: "tgt-nginx-2",
Name: "NGINX Server 2",
Type: domain.TargetTypeNGINX,
AgentID: "agent-2",
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
targetRepo.AddTarget(target1)
targetRepo.AddTarget(target2)
// Create deployment jobs
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
if err != nil {
t.Fatalf("CreateDeploymentJobs failed: %v", err)
}
// Verify 2 jobs were created
if len(jobIDs) != 2 {
t.Errorf("expected 2 jobs, got %d", len(jobIDs))
}
// Verify jobs are of correct type and status
for _, jobID := range jobIDs {
job, ok := jobRepo.Jobs[jobID]
if !ok {
t.Fatalf("job %s not found", jobID)
}
if job.Type != domain.JobTypeDeployment {
t.Errorf("expected job type Deployment, got %v", job.Type)
}
if job.Status != domain.JobStatusPending {
t.Errorf("expected job status Pending, got %v", job.Status)
}
if job.CertificateID != "mc-cert-1" {
t.Errorf("expected CertificateID mc-cert-1, got %s", job.CertificateID)
}
if job.TargetID == nil || len(*job.TargetID) == 0 {
t.Errorf("expected job to have TargetID set")
}
}
}
// TestDeploymentService_CreateDeploymentJobs_NoTargets tests error when no targets exist.
func TestDeploymentService_CreateDeploymentJobs_NoTargets(t *testing.T) {
ctx := context.Background()
svc, _, _, _, _, _, _ := newTestDeploymentService()
// No targets added, so ListByCertificate returns empty slice
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "no targets found") {
t.Errorf("expected error containing 'no targets found', got %v", err)
}
if len(jobIDs) != 0 {
t.Errorf("expected 0 job IDs, got %d", len(jobIDs))
}
}
// TestDeploymentService_CreateDeploymentJobs_TargetListError tests error from target list.
func TestDeploymentService_CreateDeploymentJobs_TargetListError(t *testing.T) {
ctx := context.Background()
svc, _, targetRepo, _, _, _, _ := newTestDeploymentService()
// Set target repo to return error
targetRepo.ListByCertErr = errNotFound
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
if err == nil {
t.Fatalf("expected error, got nil")
}
if len(jobIDs) != 0 {
t.Errorf("expected 0 job IDs, got %d", len(jobIDs))
}
}
// TestDeploymentService_CreateDeploymentJobs_AllJobCreationsFail tests when all job creations fail.
func TestDeploymentService_CreateDeploymentJobs_AllJobCreationsFail(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, _, _, _, _ := newTestDeploymentService()
// Add targets but job creation will fail
target := &domain.DeploymentTarget{
ID: "tgt-1",
Name: "Test Target",
Type: domain.TargetTypeNGINX,
AgentID: "agent-1",
}
targetRepo.AddTarget(target)
// Set job repo to fail all creates
jobRepo.CreateErr = errNotFound
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create any deployment jobs") {
t.Errorf("expected error containing 'failed to create any deployment jobs', got %v", err)
}
if len(jobIDs) != 0 {
t.Errorf("expected 0 job IDs, got %d", len(jobIDs))
}
}
// TestDeploymentService_CreateDeploymentJobs_AuditEvent tests that audit event is recorded.
func TestDeploymentService_CreateDeploymentJobs_AuditEvent(t *testing.T) {
ctx := context.Background()
svc, _, targetRepo, _, _, auditRepo, _ := newTestDeploymentService()
// Add a target
target := &domain.DeploymentTarget{
ID: "tgt-1",
Name: "Test Target",
Type: domain.TargetTypeNGINX,
AgentID: "agent-1",
}
targetRepo.AddTarget(target)
_, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
if err != nil {
t.Fatalf("CreateDeploymentJobs failed: %v", err)
}
// Check audit event
if len(auditRepo.Events) == 0 {
t.Errorf("expected at least 1 audit event, got %d", len(auditRepo.Events))
}
found := false
for _, event := range auditRepo.Events {
if event.Action == "deployment_jobs_created" {
found = true
break
}
}
if !found {
t.Errorf("expected audit event with action 'deployment_jobs_created'")
}
}
// TestDeploymentService_ProcessDeploymentJob_Success tests successful job processing.
func TestDeploymentService_ProcessDeploymentJob_Success(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, agentRepo, certRepo, _, _ := newTestDeploymentService()
// Create job with TargetID
targetID := "tgt-1"
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: &targetID,
Status: domain.JobStatusPending,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Add target with AgentID
target := &domain.DeploymentTarget{
ID: targetID,
Name: "Test Target",
Type: domain.TargetTypeNGINX,
AgentID: "agent-1",
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
targetRepo.AddTarget(target)
// Add agent with recent heartbeat
now := time.Now()
agent := &domain.Agent{
ID: "agent-1",
Name: "Test Agent",
Hostname: "agent.example.com",
Status: domain.AgentStatusOnline,
LastHeartbeatAt: &now,
RegisteredAt: time.Now(),
APIKeyHash: "hash-1",
OS: "linux",
Architecture: "amd64",
IPAddress: "192.168.1.1",
Version: "1.0.0",
}
agentRepo.AddAgent(agent)
// Add certificate
cert := &domain.ManagedCertificate{
ID: "mc-cert-1",
Name: "Test Cert",
CommonName: "example.com",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(1, 0, 0),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
// Process the job
err := svc.ProcessDeploymentJob(ctx, job)
if err != nil {
t.Fatalf("ProcessDeploymentJob failed: %v", err)
}
// Verify job status was updated to Running
if status, ok := jobRepo.StatusUpdates["job-1"]; !ok || status != domain.JobStatusRunning {
t.Errorf("expected job status Running, got %v", status)
}
}
// TestDeploymentService_ProcessDeploymentJob_CertNotFound tests handling when cert is not found.
func TestDeploymentService_ProcessDeploymentJob_CertNotFound(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, agentRepo, certRepo, _, _ := newTestDeploymentService()
// Create job
targetID := "tgt-1"
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: &targetID,
Status: domain.JobStatusPending,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Add target
target := &domain.DeploymentTarget{
ID: targetID,
AgentID: "agent-1",
}
targetRepo.AddTarget(target)
// Add agent
now := time.Now()
agent := &domain.Agent{
ID: "agent-1",
Status: domain.AgentStatusOnline,
LastHeartbeatAt: &now,
}
agentRepo.AddAgent(agent)
// Set cert repo to return error
certRepo.GetErr = errNotFound
// Process the job
err := svc.ProcessDeploymentJob(ctx, job)
if err == nil {
t.Fatalf("expected error, got nil")
}
// Verify job status was updated to Failed
if status, ok := jobRepo.StatusUpdates["job-1"]; !ok || status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %v", status)
}
}
// TestDeploymentService_ProcessDeploymentJob_NoTargetID tests handling when TargetID is missing.
func TestDeploymentService_ProcessDeploymentJob_NoTargetID(t *testing.T) {
ctx := context.Background()
svc, jobRepo, _, _, _, _, _ := newTestDeploymentService()
// Create job without TargetID
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: nil,
Status: domain.JobStatusPending,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Process the job
err := svc.ProcessDeploymentJob(ctx, job)
if err == nil {
t.Fatalf("expected error, got nil")
}
// Verify job status was updated to Failed
if status, ok := jobRepo.StatusUpdates["job-1"]; !ok || status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %v", status)
}
}
// TestDeploymentService_ProcessDeploymentJob_TargetNotFound tests handling when target is not found.
func TestDeploymentService_ProcessDeploymentJob_TargetNotFound(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, agentRepo, certRepo, _, _ := newTestDeploymentService()
// Create job
targetID := "tgt-1"
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: &targetID,
Status: domain.JobStatusPending,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Add agent
now := time.Now()
agent := &domain.Agent{
ID: "agent-1",
Status: domain.AgentStatusOnline,
LastHeartbeatAt: &now,
}
agentRepo.AddAgent(agent)
// Add certificate
cert := &domain.ManagedCertificate{
ID: "mc-cert-1",
Name: "Test Cert",
Status: domain.CertificateStatusActive,
}
certRepo.AddCert(cert)
// Set target repo to return error
targetRepo.GetErr = errNotFound
// Process the job
err := svc.ProcessDeploymentJob(ctx, job)
if err == nil {
t.Fatalf("expected error, got nil")
}
// Verify job status was updated to Failed
if status, ok := jobRepo.StatusUpdates["job-1"]; !ok || status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %v", status)
}
}
// TestDeploymentService_ProcessDeploymentJob_AgentNotFound tests handling when agent is not found.
func TestDeploymentService_ProcessDeploymentJob_AgentNotFound(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, agentRepo, certRepo, _, _ := newTestDeploymentService()
// Create job
targetID := "tgt-1"
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: &targetID,
Status: domain.JobStatusPending,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Add target with AgentID
target := &domain.DeploymentTarget{
ID: targetID,
AgentID: "agent-1",
}
targetRepo.AddTarget(target)
// Add certificate
cert := &domain.ManagedCertificate{
ID: "mc-cert-1",
Name: "Test Cert",
Status: domain.CertificateStatusActive,
}
certRepo.AddCert(cert)
// Set agent repo to return error
agentRepo.GetErr = errNotFound
// Process the job
err := svc.ProcessDeploymentJob(ctx, job)
if err == nil {
t.Fatalf("expected error, got nil")
}
// Verify job status was updated to Failed
if status, ok := jobRepo.StatusUpdates["job-1"]; !ok || status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %v", status)
}
}
// TestDeploymentService_ProcessDeploymentJob_AgentOffline tests handling when agent is offline.
func TestDeploymentService_ProcessDeploymentJob_AgentOffline(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, agentRepo, certRepo, _, _ := newTestDeploymentService()
// Create job
targetID := "tgt-1"
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: &targetID,
Status: domain.JobStatusPending,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Add target
target := &domain.DeploymentTarget{
ID: targetID,
AgentID: "agent-1",
}
targetRepo.AddTarget(target)
// Add agent with old heartbeat (offline)
oldTime := time.Now().Add(-10 * time.Minute)
agent := &domain.Agent{
ID: "agent-1",
Status: domain.AgentStatusOnline,
LastHeartbeatAt: &oldTime,
}
agentRepo.AddAgent(agent)
// Add certificate
cert := &domain.ManagedCertificate{
ID: "mc-cert-1",
Name: "Test Cert",
Status: domain.CertificateStatusActive,
}
certRepo.AddCert(cert)
// Process the job
err := svc.ProcessDeploymentJob(ctx, job)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "offline") {
t.Errorf("expected error containing 'offline', got %v", err)
}
// Verify job status was updated to Failed
if status, ok := jobRepo.StatusUpdates["job-1"]; !ok || status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %v", status)
}
}
// TestDeploymentService_ValidateDeployment_Completed tests successful validation.
func TestDeploymentService_ValidateDeployment_Completed(t *testing.T) {
ctx := context.Background()
svc, jobRepo, _, _, _, _, _ := newTestDeploymentService()
// Create completed deployment job
targetID := "tgt-1"
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: &targetID,
Status: domain.JobStatusCompleted,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Validate deployment
success, err := svc.ValidateDeployment(ctx, "mc-cert-1", "tgt-1")
if err != nil {
t.Fatalf("ValidateDeployment failed: %v", err)
}
if !success {
t.Errorf("expected success=true, got %v", success)
}
}
// TestDeploymentService_ValidateDeployment_Failed tests validation of failed deployment.
func TestDeploymentService_ValidateDeployment_Failed(t *testing.T) {
ctx := context.Background()
svc, jobRepo, _, _, _, _, _ := newTestDeploymentService()
// Create failed deployment job
targetID := "tgt-1"
errMsg := "deployment failed"
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: &targetID,
Status: domain.JobStatusFailed,
LastError: &errMsg,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Validate deployment
success, err := svc.ValidateDeployment(ctx, "mc-cert-1", "tgt-1")
if err == nil {
t.Fatalf("expected error, got nil")
}
if success {
t.Errorf("expected success=false, got %v", success)
}
}
// TestDeploymentService_ValidateDeployment_InProgress tests validation of in-progress deployment.
func TestDeploymentService_ValidateDeployment_InProgress(t *testing.T) {
ctx := context.Background()
svc, jobRepo, _, _, _, _, _ := newTestDeploymentService()
// Create running deployment job
targetID := "tgt-1"
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: &targetID,
Status: domain.JobStatusRunning,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Validate deployment
success, err := svc.ValidateDeployment(ctx, "mc-cert-1", "tgt-1")
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "in progress") {
t.Errorf("expected error containing 'in progress', got %v", err)
}
if success {
t.Errorf("expected success=false, got %v", success)
}
}
// TestDeploymentService_ValidateDeployment_NoJob tests validation when no job exists.
func TestDeploymentService_ValidateDeployment_NoJob(t *testing.T) {
ctx := context.Background()
svc, _, _, _, _, _, _ := newTestDeploymentService()
// No jobs added
// Validate deployment
success, err := svc.ValidateDeployment(ctx, "mc-cert-1", "tgt-1")
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "no deployment job found") {
t.Errorf("expected error containing 'no deployment job found', got %v", err)
}
if success {
t.Errorf("expected success=false, got %v", success)
}
}
// TestDeploymentService_MarkDeploymentComplete_Success tests successful completion marking.
func TestDeploymentService_MarkDeploymentComplete_Success(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, _, certRepo, auditRepo, _ := newTestDeploymentService()
// Create job
targetID := "tgt-1"
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: &targetID,
Status: domain.JobStatusRunning,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Add target
target := &domain.DeploymentTarget{
ID: targetID,
Name: "Test Target",
AgentID: "agent-1",
}
targetRepo.AddTarget(target)
// Add certificate
cert := &domain.ManagedCertificate{
ID: "mc-cert-1",
Name: "Test Cert",
Status: domain.CertificateStatusActive,
}
certRepo.AddCert(cert)
// Mark deployment complete
err := svc.MarkDeploymentComplete(ctx, "job-1")
if err != nil {
t.Fatalf("MarkDeploymentComplete failed: %v", err)
}
// Verify job status was updated to Completed
if status, ok := jobRepo.StatusUpdates["job-1"]; !ok || status != domain.JobStatusCompleted {
t.Errorf("expected job status Completed, got %v", status)
}
// Verify audit event was recorded
found := false
for _, event := range auditRepo.Events {
if event.Action == "deployment_job_completed" {
found = true
break
}
}
if !found {
t.Errorf("expected audit event for deployment_job_completed")
}
}
// TestDeploymentService_MarkDeploymentComplete_JobNotFound tests error when job not found.
func TestDeploymentService_MarkDeploymentComplete_JobNotFound(t *testing.T) {
ctx := context.Background()
svc, jobRepo, _, _, _, _, _ := newTestDeploymentService()
// Set job repo to return error
jobRepo.GetErr = errNotFound
// Mark deployment complete
err := svc.MarkDeploymentComplete(ctx, "job-1")
if err == nil {
t.Fatalf("expected error, got nil")
}
}
// TestDeploymentService_MarkDeploymentComplete_NoTargetID tests completion without target ID.
func TestDeploymentService_MarkDeploymentComplete_NoTargetID(t *testing.T) {
ctx := context.Background()
svc, jobRepo, _, _, certRepo, _, _ := newTestDeploymentService()
// Create job without TargetID
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: nil,
Status: domain.JobStatusRunning,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Add certificate
cert := &domain.ManagedCertificate{
ID: "mc-cert-1",
Name: "Test Cert",
Status: domain.CertificateStatusActive,
}
certRepo.AddCert(cert)
// Mark deployment complete (should succeed, just no notification)
err := svc.MarkDeploymentComplete(ctx, "job-1")
if err != nil {
t.Fatalf("MarkDeploymentComplete failed: %v", err)
}
// Verify job status was updated to Completed
if status, ok := jobRepo.StatusUpdates["job-1"]; !ok || status != domain.JobStatusCompleted {
t.Errorf("expected job status Completed, got %v", status)
}
}
// TestDeploymentService_MarkDeploymentFailed_Success tests successful failure marking.
func TestDeploymentService_MarkDeploymentFailed_Success(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, _, certRepo, auditRepo, _ := newTestDeploymentService()
// Create job
targetID := "tgt-1"
job := &domain.Job{
ID: "job-1",
Type: domain.JobTypeDeployment,
CertificateID: "mc-cert-1",
TargetID: &targetID,
Status: domain.JobStatusRunning,
CreatedAt: time.Now(),
}
jobRepo.AddJob(job)
// Add target
target := &domain.DeploymentTarget{
ID: targetID,
Name: "Test Target",
AgentID: "agent-1",
}
targetRepo.AddTarget(target)
// Add certificate
cert := &domain.ManagedCertificate{
ID: "mc-cert-1",
Name: "Test Cert",
Status: domain.CertificateStatusActive,
}
certRepo.AddCert(cert)
// Mark deployment failed
err := svc.MarkDeploymentFailed(ctx, "job-1", "connection timeout")
if err != nil {
t.Fatalf("MarkDeploymentFailed failed: %v", err)
}
// Verify job status was updated to Failed
if status, ok := jobRepo.StatusUpdates["job-1"]; !ok || status != domain.JobStatusFailed {
t.Errorf("expected job status Failed, got %v", status)
}
// Verify LastError is set
if jobRepo.Jobs["job-1"].LastError == nil || *jobRepo.Jobs["job-1"].LastError != "connection timeout" {
t.Errorf("expected LastError to be 'connection timeout', got %v", jobRepo.Jobs["job-1"].LastError)
}
// Verify audit event was recorded
found := false
for _, event := range auditRepo.Events {
if event.Action == "deployment_job_failed" {
found = true
break
}
}
if !found {
t.Errorf("expected audit event for deployment_job_failed")
}
}
// TestDeploymentService_MarkDeploymentFailed_JobNotFound tests error when job not found.
func TestDeploymentService_MarkDeploymentFailed_JobNotFound(t *testing.T) {
ctx := context.Background()
svc, jobRepo, _, _, _, _, _ := newTestDeploymentService()
// Set job repo to return error
jobRepo.GetErr = errNotFound
// Mark deployment failed
err := svc.MarkDeploymentFailed(ctx, "job-1", "error message")
if err == nil {
t.Fatalf("expected error, got nil")
}
}
+373
View File
@@ -0,0 +1,373 @@
package service
import (
"bytes"
"context"
"fmt"
"html/template"
"log/slog"
"time"
"github.com/shankar0123/certctl/internal/repository"
)
// DigestService generates and sends periodic certificate digest emails.
// It aggregates statistics from StatsService and sends HTML-formatted
// summary emails to configured recipients.
type DigestService struct {
statsService *StatsService
certRepo repository.CertificateRepository
ownerRepo repository.OwnerRepository
emailSender HTMLEmailSender
recipients []string
logger *slog.Logger
}
// HTMLEmailSender defines the interface for sending HTML emails.
// Implemented by the email notifier adapter.
type HTMLEmailSender interface {
SendHTML(ctx context.Context, recipient string, subject string, htmlBody string) error
}
// DigestData holds the aggregated data for a digest email.
type DigestData struct {
GeneratedAt time.Time `json:"generated_at"`
TotalCertificates int64 `json:"total_certificates"`
ExpiringCertificates int64 `json:"expiring_certificates"`
ExpiredCertificates int64 `json:"expired_certificates"`
RevokedCertificates int64 `json:"revoked_certificates"`
ActiveAgents int64 `json:"active_agents"`
OfflineAgents int64 `json:"offline_agents"`
TotalAgents int64 `json:"total_agents"`
PendingJobs int64 `json:"pending_jobs"`
FailedJobs int64 `json:"failed_jobs"`
CompletedJobs int64 `json:"completed_jobs"`
ExpiringCerts []DigestCertEntry `json:"expiring_certs"`
RecentFailures []DigestJobEntry `json:"recent_failures"`
StatusCounts []DigestStatusCount `json:"status_counts"`
}
// DigestCertEntry represents a certificate entry in the digest.
type DigestCertEntry struct {
ID string `json:"id"`
CommonName string `json:"common_name"`
ExpiresAt time.Time `json:"expires_at"`
DaysLeft int `json:"days_left"`
OwnerID string `json:"owner_id"`
}
// DigestJobEntry represents a failed job entry in the digest.
type DigestJobEntry struct {
ID string `json:"id"`
CertificateID string `json:"certificate_id"`
Type string `json:"type"`
Error string `json:"error"`
}
// DigestStatusCount represents certificate counts by status for the digest.
type DigestStatusCount struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
// NewDigestService creates a new digest service.
func NewDigestService(
statsService *StatsService,
certRepo repository.CertificateRepository,
ownerRepo repository.OwnerRepository,
emailSender HTMLEmailSender,
recipients []string,
logger *slog.Logger,
) *DigestService {
if logger == nil {
logger = slog.Default()
}
return &DigestService{
statsService: statsService,
certRepo: certRepo,
ownerRepo: ownerRepo,
emailSender: emailSender,
recipients: recipients,
logger: logger,
}
}
// GenerateDigest aggregates current system statistics into a DigestData struct.
func (s *DigestService) GenerateDigest(ctx context.Context) (*DigestData, error) {
digest := &DigestData{
GeneratedAt: time.Now(),
}
// Get dashboard summary
summaryRaw, err := s.statsService.GetDashboardSummary(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get dashboard summary: %w", err)
}
if summary, ok := summaryRaw.(*DashboardSummary); ok {
digest.TotalCertificates = summary.TotalCertificates
digest.ExpiringCertificates = summary.ExpiringCertificates
digest.ExpiredCertificates = summary.ExpiredCertificates
digest.RevokedCertificates = summary.RevokedCertificates
digest.ActiveAgents = summary.ActiveAgents
digest.OfflineAgents = summary.OfflineAgents
digest.TotalAgents = summary.TotalAgents
digest.PendingJobs = summary.PendingJobs
digest.FailedJobs = summary.FailedJobs
digest.CompletedJobs = summary.CompleteJobs
}
// Get certificates by status
statusRaw, err := s.statsService.GetCertificatesByStatus(ctx)
if err != nil {
s.logger.Warn("failed to get status counts for digest", "error", err)
} else if counts, ok := statusRaw.([]CertificateStatusCount); ok {
for _, c := range counts {
digest.StatusCounts = append(digest.StatusCounts, DigestStatusCount(c))
}
}
// Get expiring certificates (next 30 days)
now := time.Now()
thirtyDaysFromNow := now.AddDate(0, 0, 30)
allCerts, _, err := s.certRepo.List(ctx, &repository.CertificateFilter{Page: 1, PerPage: 10000})
if err != nil {
s.logger.Warn("failed to list certs for digest", "error", err)
} else {
for _, cert := range allCerts {
if !cert.ExpiresAt.IsZero() && cert.ExpiresAt.After(now) && cert.ExpiresAt.Before(thirtyDaysFromNow) {
daysLeft := int(time.Until(cert.ExpiresAt).Hours() / 24)
digest.ExpiringCerts = append(digest.ExpiringCerts, DigestCertEntry{
ID: cert.ID,
CommonName: cert.CommonName,
ExpiresAt: cert.ExpiresAt,
DaysLeft: daysLeft,
OwnerID: cert.OwnerID,
})
}
}
}
return digest, nil
}
// SendDigest generates a digest and sends it to all configured recipients.
func (s *DigestService) SendDigest(ctx context.Context) error {
if s.emailSender == nil {
return fmt.Errorf("email sender not configured — set CERTCTL_SMTP_HOST and CERTCTL_SMTP_FROM_ADDRESS")
}
digest, err := s.GenerateDigest(ctx)
if err != nil {
return fmt.Errorf("failed to generate digest: %w", err)
}
htmlBody, err := s.RenderDigestHTML(digest)
if err != nil {
return fmt.Errorf("failed to render digest HTML: %w", err)
}
subject := fmt.Sprintf("certctl Certificate Digest — %s", digest.GeneratedAt.Format("2006-01-02"))
recipients := s.recipients
if len(recipients) == 0 {
// Fall back to owner emails
recipients = s.resolveOwnerEmails(ctx)
}
if len(recipients) == 0 {
s.logger.Warn("no digest recipients configured and no owner emails found")
return nil
}
var sendErrors int
for _, recipient := range recipients {
if err := s.emailSender.SendHTML(ctx, recipient, subject, htmlBody); err != nil {
s.logger.Error("failed to send digest to recipient",
"recipient", recipient,
"error", err)
sendErrors++
} else {
s.logger.Info("digest email sent", "recipient", recipient)
}
}
if sendErrors > 0 {
return fmt.Errorf("failed to send digest to %d of %d recipients", sendErrors, len(recipients))
}
return nil
}
// ProcessDigest is the scheduler-facing method. It generates and sends the digest,
// logging errors rather than propagating them to match the scheduler pattern.
func (s *DigestService) ProcessDigest(ctx context.Context) error {
return s.SendDigest(ctx)
}
// RenderDigestHTML renders the digest data into an HTML email body.
func (s *DigestService) RenderDigestHTML(data *DigestData) (string, error) {
tmpl, err := template.New("digest").Parse(digestHTMLTemplate)
if err != nil {
return "", fmt.Errorf("failed to parse digest template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute digest template: %w", err)
}
return buf.String(), nil
}
// PreviewDigest generates and renders a digest without sending it.
// Used by the API handler for preview endpoints.
func (s *DigestService) PreviewDigest(ctx context.Context) (string, error) {
digest, err := s.GenerateDigest(ctx)
if err != nil {
return "", fmt.Errorf("failed to generate digest: %w", err)
}
return s.RenderDigestHTML(digest)
}
// resolveOwnerEmails collects unique email addresses from all certificate owners.
func (s *DigestService) resolveOwnerEmails(ctx context.Context) []string {
if s.ownerRepo == nil {
return nil
}
owners, err := s.ownerRepo.List(ctx)
if err != nil {
s.logger.Warn("failed to list owners for digest recipients", "error", err)
return nil
}
seen := make(map[string]bool)
var emails []string
for _, owner := range owners {
if owner.Email != "" && !seen[owner.Email] {
seen[owner.Email] = true
emails = append(emails, owner.Email)
}
}
return emails
}
// digestHTMLTemplate is the HTML template for the certificate digest email.
const digestHTMLTemplate = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>certctl Certificate Digest</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background: #f5f5f5; color: #333; }
.container { max-width: 640px; margin: 0 auto; background: #fff; }
.header { background: #1a1a2e; color: #fff; padding: 24px 32px; }
.header h1 { margin: 0; font-size: 22px; font-weight: 600; }
.header .date { color: #a0a0b0; font-size: 13px; margin-top: 4px; }
.section { padding: 24px 32px; border-bottom: 1px solid #eee; }
.section h2 { font-size: 16px; font-weight: 600; margin: 0 0 16px 0; color: #1a1a2e; }
.stats-grid { display: flex; flex-wrap: wrap; gap: 12px; }
.stat-card { flex: 1; min-width: 120px; background: #f8f9fa; border-radius: 8px; padding: 16px; text-align: center; }
.stat-value { font-size: 28px; font-weight: 700; color: #1a1a2e; }
.stat-label { font-size: 12px; color: #666; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-warn .stat-value { color: #e67e22; }
.stat-danger .stat-value { color: #e74c3c; }
.stat-success .stat-value { color: #27ae60; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 8px 12px; background: #f8f9fa; color: #666; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.badge-warn { background: #fef3e2; color: #e67e22; }
.badge-danger { background: #fde8e8; color: #e74c3c; }
.badge-ok { background: #e8f8ef; color: #27ae60; }
.footer { padding: 20px 32px; text-align: center; color: #999; font-size: 12px; background: #f8f9fa; }
.empty-state { text-align: center; padding: 24px; color: #999; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>certctl Certificate Digest</h1>
<div class="date">Generated: {{.GeneratedAt.Format "January 2, 2006 3:04 PM"}}</div>
</div>
<div class="section">
<h2>System Overview</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{.TotalCertificates}}</div>
<div class="stat-label">Total Certs</div>
</div>
<div class="stat-card stat-warn">
<div class="stat-value">{{.ExpiringCertificates}}</div>
<div class="stat-label">Expiring</div>
</div>
<div class="stat-card stat-danger">
<div class="stat-value">{{.ExpiredCertificates}}</div>
<div class="stat-label">Expired</div>
</div>
<div class="stat-card stat-success">
<div class="stat-value">{{.ActiveAgents}}</div>
<div class="stat-label">Active Agents</div>
</div>
</div>
</div>
<div class="section">
<h2>Jobs Summary</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{.PendingJobs}}</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat-card stat-danger">
<div class="stat-value">{{.FailedJobs}}</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat-card stat-success">
<div class="stat-value">{{.CompletedJobs}}</div>
<div class="stat-label">Completed</div>
</div>
</div>
</div>
{{if .ExpiringCerts}}
<div class="section">
<h2>Certificates Expiring Soon</h2>
<table>
<thead>
<tr><th>Common Name</th><th>Expires</th><th>Days Left</th></tr>
</thead>
<tbody>
{{range .ExpiringCerts}}
<tr>
<td>{{.CommonName}}</td>
<td>{{.ExpiresAt.Format "Jan 2, 2006"}}</td>
<td>
{{if le .DaysLeft 7}}<span class="badge badge-danger">{{.DaysLeft}} days</span>
{{else if le .DaysLeft 14}}<span class="badge badge-warn">{{.DaysLeft}} days</span>
{{else}}<span class="badge badge-ok">{{.DaysLeft}} days</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="section">
<h2>Certificates Expiring Soon</h2>
<div class="empty-state">No certificates expiring in the next 30 days.</div>
</div>
{{end}}
<div class="footer">
This digest was automatically generated by certctl.<br>
Configure digest settings with CERTCTL_DIGEST_* environment variables.
</div>
</div>
</body>
</html>`
+309
View File
@@ -0,0 +1,309 @@
package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// mockHTMLEmailSender implements HTMLEmailSender for testing.
type mockHTMLEmailSender struct {
sentEmails []sentHTMLEmail
sendErr error
}
type sentHTMLEmail struct {
recipient string
subject string
body string
}
func (m *mockHTMLEmailSender) SendHTML(ctx context.Context, recipient string, subject string, htmlBody string) error {
if m.sendErr != nil {
return m.sendErr
}
m.sentEmails = append(m.sentEmails, sentHTMLEmail{
recipient: recipient,
subject: subject,
body: htmlBody,
})
return nil
}
func TestDigestService_GenerateDigest(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
// Add test certificates
now := time.Now()
certRepo.Certs["cert-1"] = &domain.ManagedCertificate{
ID: "cert-1",
CommonName: "example.com",
ExpiresAt: now.AddDate(0, 0, 10),
OwnerID: "owner-1",
}
certRepo.Certs["cert-2"] = &domain.ManagedCertificate{
ID: "cert-2",
CommonName: "api.example.com",
ExpiresAt: now.AddDate(0, 0, 25),
OwnerID: "owner-2",
}
certRepo.Certs["cert-3"] = &domain.ManagedCertificate{
ID: "cert-3",
CommonName: "old.example.com",
ExpiresAt: now.AddDate(0, 0, -5), // expired
OwnerID: "owner-1",
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
digestService := NewDigestService(statsService, certRepo, nil, sender, []string{"admin@example.com"}, nil)
digest, err := digestService.GenerateDigest(context.Background())
if err != nil {
t.Fatalf("GenerateDigest failed: %v", err)
}
if digest.TotalCertificates != 3 {
t.Errorf("expected 3 total certs, got %d", digest.TotalCertificates)
}
if len(digest.ExpiringCerts) != 2 {
t.Errorf("expected 2 expiring certs (10d and 25d), got %d", len(digest.ExpiringCerts))
}
}
func TestDigestService_GenerateDigest_Empty(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
digestService := NewDigestService(statsService, certRepo, nil, sender, nil, nil)
digest, err := digestService.GenerateDigest(context.Background())
if err != nil {
t.Fatalf("GenerateDigest failed: %v", err)
}
if digest.TotalCertificates != 0 {
t.Errorf("expected 0 total certs, got %d", digest.TotalCertificates)
}
if len(digest.ExpiringCerts) != 0 {
t.Errorf("expected 0 expiring certs, got %d", len(digest.ExpiringCerts))
}
}
func TestDigestService_RenderDigestHTML(t *testing.T) {
digestService := &DigestService{}
data := &DigestData{
GeneratedAt: time.Now(),
TotalCertificates: 42,
ExpiringCertificates: 5,
ExpiredCertificates: 2,
ActiveAgents: 3,
PendingJobs: 1,
ExpiringCerts: []DigestCertEntry{
{ID: "c1", CommonName: "example.com", ExpiresAt: time.Now().AddDate(0, 0, 5), DaysLeft: 5},
},
}
html, err := digestService.RenderDigestHTML(data)
if err != nil {
t.Fatalf("RenderDigestHTML failed: %v", err)
}
if !strings.Contains(html, "certctl Certificate Digest") {
t.Error("expected HTML to contain 'certctl Certificate Digest'")
}
if !strings.Contains(html, "42") {
t.Error("expected HTML to contain total certificate count '42'")
}
if !strings.Contains(html, "example.com") {
t.Error("expected HTML to contain 'example.com'")
}
if !strings.Contains(html, "5 days") {
t.Error("expected HTML to contain '5 days'")
}
}
func TestDigestService_RenderDigestHTML_Empty(t *testing.T) {
digestService := &DigestService{}
data := &DigestData{
GeneratedAt: time.Now(),
}
html, err := digestService.RenderDigestHTML(data)
if err != nil {
t.Fatalf("RenderDigestHTML failed: %v", err)
}
if !strings.Contains(html, "No certificates expiring in the next 30 days") {
t.Error("expected empty state message in HTML")
}
}
func TestDigestService_SendDigest_Success(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
recipients := []string{"admin@example.com", "ops@example.com"}
digestService := NewDigestService(statsService, certRepo, nil, sender, recipients, nil)
err := digestService.SendDigest(context.Background())
if err != nil {
t.Fatalf("SendDigest failed: %v", err)
}
if len(sender.sentEmails) != 2 {
t.Fatalf("expected 2 emails sent, got %d", len(sender.sentEmails))
}
if sender.sentEmails[0].recipient != "admin@example.com" {
t.Errorf("expected first recipient admin@example.com, got %s", sender.sentEmails[0].recipient)
}
if !strings.Contains(sender.sentEmails[0].subject, "certctl Certificate Digest") {
t.Errorf("expected subject to contain 'certctl Certificate Digest', got %s", sender.sentEmails[0].subject)
}
}
func TestDigestService_SendDigest_NoSender(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
digestService := NewDigestService(statsService, certRepo, nil, nil, []string{"admin@example.com"}, nil)
err := digestService.SendDigest(context.Background())
if err == nil {
t.Fatal("expected error when sender is nil")
}
if !strings.Contains(err.Error(), "email sender not configured") {
t.Errorf("expected 'email sender not configured' error, got: %v", err)
}
}
func TestDigestService_SendDigest_SendError(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{sendErr: errors.New("SMTP connection refused")}
digestService := NewDigestService(statsService, certRepo, nil, sender, []string{"admin@example.com"}, nil)
err := digestService.SendDigest(context.Background())
if err == nil {
t.Fatal("expected error when send fails")
}
if !strings.Contains(err.Error(), "failed to send digest") {
t.Errorf("expected 'failed to send digest' error, got: %v", err)
}
}
func TestDigestService_SendDigest_NoRecipients(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
// No explicit recipients and no owner repo
digestService := NewDigestService(statsService, certRepo, nil, sender, nil, nil)
err := digestService.SendDigest(context.Background())
// Should succeed without error (just no recipients)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(sender.sentEmails) != 0 {
t.Errorf("expected 0 emails sent, got %d", len(sender.sentEmails))
}
}
func TestDigestService_PreviewDigest(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
digestService := NewDigestService(statsService, certRepo, nil, sender, nil, nil)
html, err := digestService.PreviewDigest(context.Background())
if err != nil {
t.Fatalf("PreviewDigest failed: %v", err)
}
if !strings.Contains(html, "<!DOCTYPE html>") {
t.Error("expected valid HTML document")
}
if !strings.Contains(html, "certctl Certificate Digest") {
t.Error("expected HTML to contain 'certctl Certificate Digest'")
}
}
func TestDigestService_ProcessDigest(t *testing.T) {
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{Jobs: make(map[string]*domain.Job)}
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
statsService := NewStatsService(certRepo, jobRepo, agentRepo)
sender := &mockHTMLEmailSender{}
digestService := NewDigestService(statsService, certRepo, nil, sender, []string{"test@example.com"}, nil)
err := digestService.ProcessDigest(context.Background())
if err != nil {
t.Fatalf("ProcessDigest failed: %v", err)
}
if len(sender.sentEmails) != 1 {
t.Errorf("expected 1 email sent, got %d", len(sender.sentEmails))
}
}
+17
View File
@@ -102,3 +102,20 @@ func (a *IssuerConnectorAdapter) SignOCSPResponse(ctx context.Context, req OCSPS
func (a *IssuerConnectorAdapter) GetCACertPEM(ctx context.Context) (string, error) {
return a.connector.GetCACertPEM(ctx)
}
// GetRenewalInfo delegates to the underlying connector, translating between service-layer and connector-layer types.
func (a *IssuerConnectorAdapter) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
result, err := a.connector.GetRenewalInfo(ctx, certPEM)
if err != nil {
return nil, err
}
if result == nil {
return nil, nil
}
return &RenewalInfoResult{
SuggestedWindowStart: result.SuggestedWindowStart,
SuggestedWindowEnd: result.SuggestedWindowEnd,
RetryAfter: result.RetryAfter,
ExplanationURL: result.ExplanationURL,
}, nil
}
+129 -10
View File
@@ -13,16 +13,19 @@ import (
// mockConnectorLayerIssuer is a test implementation of issuer.Connector
type mockConnectorLayerIssuer struct {
issueResult *issuer.IssuanceResult
issueErr error
renewResult *issuer.IssuanceResult
renewErr error
lastIssueReq *issuer.IssuanceRequest
lastRenewReq *issuer.RenewalRequest
validateErr error
revokeErr error
orderStatusErr error
orderStatus *issuer.OrderStatus
issueResult *issuer.IssuanceResult
issueErr error
renewResult *issuer.IssuanceResult
renewErr error
lastIssueReq *issuer.IssuanceRequest
lastRenewReq *issuer.RenewalRequest
validateErr error
revokeErr error
orderStatusErr error
orderStatus *issuer.OrderStatus
renewalInfoResult *issuer.RenewalInfoResult
renewalInfoErr error
renewalInfoNil bool // flag to force nil result
}
func (m *mockConnectorLayerIssuer) ValidateConfig(ctx context.Context, config json.RawMessage) error {
@@ -100,6 +103,23 @@ func (m *mockConnectorLayerIssuer) GetCACertPEM(ctx context.Context) (string, er
return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil
}
func (m *mockConnectorLayerIssuer) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
if m.renewalInfoErr != nil {
return nil, m.renewalInfoErr
}
if m.renewalInfoNil {
return nil, nil
}
if m.renewalInfoResult != nil {
return m.renewalInfoResult, nil
}
now := time.Now()
return &issuer.RenewalInfoResult{
SuggestedWindowStart: now,
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour),
}, nil
}
// Tests for IssueCertificate
func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) {
@@ -527,3 +547,102 @@ func TestIssuerConnectorAdapter_SignOCSPResponse_Unknown(t *testing.T) {
t.Log("OCSP response for unknown cert signed via adapter")
}
// Tests for GetRenewalInfo
func TestIssuerConnectorAdapter_GetRenewalInfo_Success(t *testing.T) {
ctx := context.Background()
mock := &mockConnectorLayerIssuer{}
adapter := NewIssuerConnectorAdapter(mock)
testCertPEM := "-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----"
result, err := adapter.GetRenewalInfo(ctx, testCertPEM)
if err != nil {
t.Fatalf("GetRenewalInfo failed: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.SuggestedWindowStart.IsZero() {
t.Error("SuggestedWindowStart should not be zero")
}
if result.SuggestedWindowEnd.IsZero() {
t.Error("SuggestedWindowEnd should not be zero")
}
if result.SuggestedWindowEnd.Before(result.SuggestedWindowStart) {
t.Error("SuggestedWindowEnd should be after SuggestedWindowStart")
}
}
func TestIssuerConnectorAdapter_GetRenewalInfo_Nil(t *testing.T) {
ctx := context.Background()
mock := &mockConnectorLayerIssuer{
renewalInfoNil: true,
}
adapter := NewIssuerConnectorAdapter(mock)
result, err := adapter.GetRenewalInfo(ctx, "test-cert-pem")
if err != nil {
t.Fatalf("GetRenewalInfo failed: %v", err)
}
if result != nil {
t.Error("expected nil result when underlying connector returns nil")
}
}
func TestIssuerConnectorAdapter_GetRenewalInfo_ResultTranslation(t *testing.T) {
ctx := context.Background()
now := time.Now()
windowStart := now
windowEnd := now.Add(24 * time.Hour)
retryAfter := now.Add(1 * time.Hour)
explanationURL := "https://example.com/renewal-info"
mock := &mockConnectorLayerIssuer{
renewalInfoResult: &issuer.RenewalInfoResult{
SuggestedWindowStart: windowStart,
SuggestedWindowEnd: windowEnd,
RetryAfter: retryAfter,
ExplanationURL: explanationURL,
},
}
adapter := NewIssuerConnectorAdapter(mock)
result, err := adapter.GetRenewalInfo(ctx, "test-cert-pem")
if err != nil {
t.Fatalf("GetRenewalInfo failed: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
if !result.SuggestedWindowStart.Equal(windowStart) {
t.Errorf("expected SuggestedWindowStart %v, got %v", windowStart, result.SuggestedWindowStart)
}
if !result.SuggestedWindowEnd.Equal(windowEnd) {
t.Errorf("expected SuggestedWindowEnd %v, got %v", windowEnd, result.SuggestedWindowEnd)
}
if !result.RetryAfter.Equal(retryAfter) {
t.Errorf("expected RetryAfter %v, got %v", retryAfter, result.RetryAfter)
}
if result.ExplanationURL != explanationURL {
t.Errorf("expected ExplanationURL %s, got %s", explanationURL, result.ExplanationURL)
}
}
+11
View File
@@ -48,6 +48,17 @@ type IssuerConnector interface {
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
GetCACertPEM(ctx context.Context) (string, error)
// GetRenewalInfo retrieves ACME Renewal Information (ARI) per RFC 9702 for a certificate.
// certPEM is the PEM-encoded certificate. Returns nil, nil if the issuer does not support ARI.
GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error)
}
// RenewalInfoResult holds the ARI response from a CA.
type RenewalInfoResult struct {
SuggestedWindowStart time.Time
SuggestedWindowEnd time.Time
RetryAfter time.Time
ExplanationURL string
}
// IssuanceResult holds the result of a certificate issuance or renewal operation.
+408
View File
@@ -0,0 +1,408 @@
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// setupShortLivedTestService creates a RenewalService with mock dependencies for short-lived cert tests
func setupShortLivedTestService(
certRepo *mockCertRepo,
profileRepo *mockProfileRepo,
auditRepo *mockAuditRepo,
) *RenewalService {
auditSvc := NewAuditService(auditRepo)
issuerRegistry := map[string]IssuerConnector{
"iss-test": &mockIssuerConnector{},
}
svc := NewRenewalService(
certRepo,
newMockJobRepository(),
newMockRenewalPolicyRepository(),
profileRepo,
auditSvc,
NewNotificationService(newMockNotificationRepository(), map[string]Notifier{}),
issuerRegistry,
"agent",
)
return svc
}
// TestExpireShortLivedCertificates_Success verifies that active certificates with
// expired short-lived profiles are transitioned to Expired status
func TestExpireShortLivedCertificates_Success(t *testing.T) {
ctx := context.Background()
now := time.Now()
certRepo := newMockCertificateRepository()
profileRepo := newMockProfileRepository()
auditRepo := newMockAuditRepository()
// Create a short-lived profile (TTL < 1 hour = 3600 seconds)
shortLivedProfile := &domain.CertificateProfile{
ID: "prof-short",
Name: "Short-Lived",
MaxTTLSeconds: 300, // 5 minutes
AllowShortLived: true,
Enabled: true,
AllowedKeyAlgorithms: domain.DefaultKeyAlgorithms(),
AllowedEKUs: domain.DefaultEKUs(),
CreatedAt: now,
UpdatedAt: now,
}
profileRepo.AddProfile(shortLivedProfile)
// Create an active certificate that has already expired
expiredCert := &domain.ManagedCertificate{
ID: "mc-expired-short",
Name: "Expired Short-Lived Cert",
CommonName: "short.example.com",
SANs: []string{},
IssuerID: "iss-test",
CertificateProfileID: "prof-short",
Status: domain.CertificateStatusActive,
ExpiresAt: now.Add(-5 * time.Minute), // Already expired
CreatedAt: now.Add(-15 * time.Minute),
UpdatedAt: now.Add(-5 * time.Minute),
Tags: make(map[string]string),
}
certRepo.AddCert(expiredCert)
svc := setupShortLivedTestService(certRepo, profileRepo, auditRepo)
// Run the expiry check
err := svc.ExpireShortLivedCertificates(ctx)
if err != nil {
t.Fatalf("ExpireShortLivedCertificates failed: %v", err)
}
// Verify the cert status was updated to Expired
updated, err := certRepo.Get(ctx, "mc-expired-short")
if err != nil {
t.Fatalf("failed to get updated cert: %v", err)
}
if updated.Status != domain.CertificateStatusExpired {
t.Errorf("expected cert status to be Expired, got %s", updated.Status)
}
// Verify an audit event was recorded
if len(auditRepo.Events) == 0 {
t.Errorf("expected audit event to be recorded, got none")
}
}
// TestExpireShortLivedCertificates_NoCertsToExpire verifies the function handles
// empty certificate lists gracefully
func TestExpireShortLivedCertificates_NoCertsToExpire(t *testing.T) {
ctx := context.Background()
certRepo := newMockCertificateRepository()
profileRepo := newMockProfileRepository()
auditRepo := newMockAuditRepository()
svc := setupShortLivedTestService(certRepo, profileRepo, auditRepo)
// Run the expiry check on empty certificate list
err := svc.ExpireShortLivedCertificates(ctx)
if err != nil {
t.Fatalf("ExpireShortLivedCertificates failed: %v", err)
}
// Verify no audit events were recorded
if len(auditRepo.Events) != 0 {
t.Errorf("expected no audit events, got %d", len(auditRepo.Events))
}
}
// TestExpireShortLivedCertificates_ListError verifies that repository errors
// are properly propagated
func TestExpireShortLivedCertificates_ListError(t *testing.T) {
ctx := context.Background()
// Create a custom mock that returns an error from GetExpiringCertificates
customCertRepo := &mockCertRepoWithGetError{
GetExpiringCertificatesErr: errors.New("database connection failed"),
}
profileRepo := newMockProfileRepository()
auditRepo := newMockAuditRepository()
// Create the service manually to use our custom cert repo
auditSvc := NewAuditService(auditRepo)
issuerRegistry := map[string]IssuerConnector{
"iss-test": &mockIssuerConnector{},
}
svc := NewRenewalService(
customCertRepo,
newMockJobRepository(),
newMockRenewalPolicyRepository(),
profileRepo,
auditSvc,
NewNotificationService(newMockNotificationRepository(), map[string]Notifier{}),
issuerRegistry,
"agent",
)
// Run the expiry check, expecting an error
err := svc.ExpireShortLivedCertificates(ctx)
if err == nil {
t.Fatalf("expected ExpireShortLivedCertificates to return an error, got nil")
}
if !errors.Is(err, customCertRepo.GetExpiringCertificatesErr) {
t.Errorf("expected error containing 'database connection failed', got %v", err)
}
}
// mockCertRepoWithGetError is a minimal custom mock for testing GetExpiringCertificates error handling
type mockCertRepoWithGetError struct {
GetExpiringCertificatesErr error
}
func (m *mockCertRepoWithGetError) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
return nil, 0, nil
}
func (m *mockCertRepoWithGetError) Get(ctx context.Context, id string) (*domain.ManagedCertificate, error) {
return nil, nil
}
func (m *mockCertRepoWithGetError) Create(ctx context.Context, cert *domain.ManagedCertificate) error {
return nil
}
func (m *mockCertRepoWithGetError) Update(ctx context.Context, cert *domain.ManagedCertificate) error {
return nil
}
func (m *mockCertRepoWithGetError) Archive(ctx context.Context, id string) error {
return nil
}
func (m *mockCertRepoWithGetError) ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
return nil, nil
}
func (m *mockCertRepoWithGetError) CreateVersion(ctx context.Context, version *domain.CertificateVersion) error {
return nil
}
func (m *mockCertRepoWithGetError) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
return nil, nil
}
func (m *mockCertRepoWithGetError) GetExpiringCertificates(ctx context.Context, before time.Time) ([]*domain.ManagedCertificate, error) {
return nil, m.GetExpiringCertificatesErr
}
// TestExpireShortLivedCertificates_PartialUpdateError verifies that update errors
// on individual certs are logged but don't fail the entire operation
func TestExpireShortLivedCertificates_PartialUpdateError(t *testing.T) {
ctx := context.Background()
now := time.Now()
certRepo := newMockCertificateRepository()
profileRepo := newMockProfileRepository()
auditRepo := newMockAuditRepository()
// Create a short-lived profile
shortLivedProfile := &domain.CertificateProfile{
ID: "prof-short",
Name: "Short-Lived",
MaxTTLSeconds: 300,
AllowShortLived: true,
Enabled: true,
AllowedKeyAlgorithms: domain.DefaultKeyAlgorithms(),
AllowedEKUs: domain.DefaultEKUs(),
CreatedAt: now,
UpdatedAt: now,
}
profileRepo.AddProfile(shortLivedProfile)
// Create a certificate with a failing update
expiredCert := &domain.ManagedCertificate{
ID: "mc-expired-fail",
Name: "Expired Cert That Will Fail",
CommonName: "fail.example.com",
SANs: []string{},
IssuerID: "iss-test",
CertificateProfileID: "prof-short",
Status: domain.CertificateStatusActive,
ExpiresAt: now.Add(-5 * time.Minute),
CreatedAt: now.Add(-15 * time.Minute),
UpdatedAt: now.Add(-5 * time.Minute),
Tags: make(map[string]string),
}
certRepo.AddCert(expiredCert)
// Set up the repo to fail on update
certRepo.UpdateErr = errors.New("update failed")
svc := setupShortLivedTestService(certRepo, profileRepo, auditRepo)
// Run the expiry check - should not return an error even though update failed
err := svc.ExpireShortLivedCertificates(ctx)
if err != nil {
t.Fatalf("ExpireShortLivedCertificates should not fail on partial update errors, got %v", err)
}
// Verify no audit events were recorded (update failure skips audit recording)
if len(auditRepo.Events) != 0 {
t.Errorf("expected no audit events on update failure, got %d", len(auditRepo.Events))
}
}
// TestExpireShortLivedCertificates_AlreadyExpired verifies that certificates
// already in Expired status are not re-processed
func TestExpireShortLivedCertificates_AlreadyExpired(t *testing.T) {
ctx := context.Background()
now := time.Now()
certRepo := newMockCertificateRepository()
profileRepo := newMockProfileRepository()
auditRepo := newMockAuditRepository()
// Create a short-lived profile
shortLivedProfile := &domain.CertificateProfile{
ID: "prof-short",
Name: "Short-Lived",
MaxTTLSeconds: 300,
AllowShortLived: true,
Enabled: true,
AllowedKeyAlgorithms: domain.DefaultKeyAlgorithms(),
AllowedEKUs: domain.DefaultEKUs(),
CreatedAt: now,
UpdatedAt: now,
}
profileRepo.AddProfile(shortLivedProfile)
// Create a certificate that's already in Expired status
alreadyExpiredCert := &domain.ManagedCertificate{
ID: "mc-already-expired",
Name: "Already Expired Cert",
CommonName: "already-expired.example.com",
SANs: []string{},
IssuerID: "iss-test",
CertificateProfileID: "prof-short",
Status: domain.CertificateStatusExpired, // Already expired
ExpiresAt: now.Add(-30 * time.Minute),
CreatedAt: now.Add(-45 * time.Minute),
UpdatedAt: now.Add(-10 * time.Minute),
Tags: make(map[string]string),
}
certRepo.AddCert(alreadyExpiredCert)
svc := setupShortLivedTestService(certRepo, profileRepo, auditRepo)
// Run the expiry check
err := svc.ExpireShortLivedCertificates(ctx)
if err != nil {
t.Fatalf("ExpireShortLivedCertificates failed: %v", err)
}
// Verify no new audit events were recorded (cert was skipped)
if len(auditRepo.Events) != 0 {
t.Errorf("expected no audit events for already-expired cert, got %d", len(auditRepo.Events))
}
}
// TestExpireShortLivedCertificates_ProfileNotShortLived verifies that certificates
// with non-short-lived profiles are not expired by this function
func TestExpireShortLivedCertificates_ProfileNotShortLived(t *testing.T) {
ctx := context.Background()
now := time.Now()
certRepo := newMockCertificateRepository()
profileRepo := newMockProfileRepository()
auditRepo := newMockAuditRepository()
// Create a regular (not short-lived) profile with TTL > 1 hour
regularProfile := &domain.CertificateProfile{
ID: "prof-regular",
Name: "Regular",
MaxTTLSeconds: 86400, // 24 hours
AllowShortLived: false,
Enabled: true,
AllowedKeyAlgorithms: domain.DefaultKeyAlgorithms(),
AllowedEKUs: domain.DefaultEKUs(),
CreatedAt: now,
UpdatedAt: now,
}
profileRepo.AddProfile(regularProfile)
// Create an expired certificate with the regular profile
expiredCert := &domain.ManagedCertificate{
ID: "mc-expired-regular",
Name: "Expired Regular Cert",
CommonName: "regular.example.com",
SANs: []string{},
IssuerID: "iss-test",
CertificateProfileID: "prof-regular",
Status: domain.CertificateStatusActive,
ExpiresAt: now.Add(-1 * time.Hour),
CreatedAt: now.Add(-25 * time.Hour),
UpdatedAt: now.Add(-1 * time.Hour),
Tags: make(map[string]string),
}
certRepo.AddCert(expiredCert)
svc := setupShortLivedTestService(certRepo, profileRepo, auditRepo)
// Run the expiry check
err := svc.ExpireShortLivedCertificates(ctx)
if err != nil {
t.Fatalf("ExpireShortLivedCertificates failed: %v", err)
}
// Verify the cert status was NOT changed (because profile is not short-lived)
cert, _ := certRepo.Get(ctx, "mc-expired-regular")
if cert.Status != domain.CertificateStatusActive {
t.Errorf("cert should not have been expired (profile not short-lived), got status %s", cert.Status)
}
// Verify no audit events were recorded
if len(auditRepo.Events) != 0 {
t.Errorf("expected no audit events for non-short-lived profile, got %d", len(auditRepo.Events))
}
}
// TestExpireShortLivedCertificates_NoProfileRepository verifies the function
// handles nil profileRepo gracefully
func TestExpireShortLivedCertificates_NoProfileRepository(t *testing.T) {
ctx := context.Background()
certRepo := newMockCertificateRepository()
auditRepo := &mockAuditRepo{
Events: make([]*domain.AuditEvent, 0),
}
auditSvc := NewAuditService(auditRepo)
issuerRegistry := map[string]IssuerConnector{
"iss-test": &mockIssuerConnector{},
}
svc := NewRenewalService(
certRepo,
newMockJobRepository(),
newMockRenewalPolicyRepository(),
nil, // nil profileRepo
auditSvc,
NewNotificationService(newMockNotificationRepository(), map[string]Notifier{}),
issuerRegistry,
"agent",
)
// Run the expiry check with nil profileRepo
err := svc.ExpireShortLivedCertificates(ctx)
if err != nil {
t.Fatalf("ExpireShortLivedCertificates should handle nil profileRepo gracefully, got error: %v", err)
}
}
+412
View File
@@ -0,0 +1,412 @@
package service
import (
"context"
"encoding/json"
"testing"
"github.com/shankar0123/certctl/internal/domain"
)
// newTestTargetService creates a TargetService with mock repositories for testing.
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo) {
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditRepo := newMockAuditRepository()
auditSvc := NewAuditService(auditRepo)
return NewTargetService(targetRepo, auditSvc), targetRepo, auditRepo
}
func TestTargetService_List_Success(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
ctx := context.Background()
// Add 3 targets
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
target2 := &domain.DeploymentTarget{ID: "t-2", Name: "Target 2", Type: domain.TargetTypeApache}
target3 := &domain.DeploymentTarget{ID: "t-3", Name: "Target 3", Type: domain.TargetTypeHAProxy}
targetRepo.AddTarget(target1)
targetRepo.AddTarget(target2)
targetRepo.AddTarget(target3)
// Request page 1, perPage 2
targets, total, err := svc.List(ctx, 1, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 2 {
t.Errorf("expected 2 targets, got %d", len(targets))
}
if total != 3 {
t.Errorf("expected total=3, got %d", total)
}
}
func TestTargetService_List_DefaultPagination(t *testing.T) {
svc, _, _ := newTestTargetService()
ctx := context.Background()
// Call with invalid pagination (page=0, perPage=0)
targets, total, err := svc.List(ctx, 0, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should not panic; should use defaults (page=1, perPage=50)
if targets != nil || total != 0 {
t.Errorf("expected empty list with defaults, got %d targets", len(targets))
}
}
func TestTargetService_List_EmptyPage(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
ctx := context.Background()
// Add 3 targets
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
target2 := &domain.DeploymentTarget{ID: "t-2", Name: "Target 2", Type: domain.TargetTypeApache}
target3 := &domain.DeploymentTarget{ID: "t-3", Name: "Target 3", Type: domain.TargetTypeHAProxy}
targetRepo.AddTarget(target1)
targetRepo.AddTarget(target2)
targetRepo.AddTarget(target3)
// Request page 2 with perPage 10 (beyond available data)
targets, total, err := svc.List(ctx, 2, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 0 {
t.Errorf("expected 0 targets, got %d", len(targets))
}
if total != 3 {
t.Errorf("expected total=3, got %d", total)
}
}
func TestTargetService_List_RepoError(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
ctx := context.Background()
// Set repo to return error
targetRepo.ListErr = errNotFound
targets, total, err := svc.List(ctx, 1, 50)
if err == nil {
t.Fatalf("expected error, got nil")
}
if targets != nil || total != 0 {
t.Errorf("expected nil targets and zero total, got %d targets and %d total", len(targets), total)
}
}
func TestTargetService_Get_Success(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
ctx := context.Background()
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
targetRepo.AddTarget(target)
result, err := svc.Get(ctx, "t-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ID != "t-1" || result.Name != "Target 1" {
t.Errorf("expected target t-1/Target 1, got %s/%s", result.ID, result.Name)
}
}
func TestTargetService_Get_NotFound(t *testing.T) {
svc, _, _ := newTestTargetService()
ctx := context.Background()
result, err := svc.Get(ctx, "nonexistent")
if err == nil {
t.Fatalf("expected error for nonexistent target, got nil")
}
if result != nil {
t.Errorf("expected nil result, got %v", result)
}
}
func TestTargetService_Create_Success(t *testing.T) {
svc, targetRepo, auditRepo := newTestTargetService()
ctx := context.Background()
target := &domain.DeploymentTarget{
Name: "New Target",
Type: domain.TargetTypeNGINX,
Config: json.RawMessage(`{"path": "/etc/nginx/certs"}`),
}
err := svc.Create(ctx, target, "test-actor")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify target was stored
if target.ID == "" || len(target.ID) < 7 || target.ID[:6] != "target" {
t.Errorf("expected ID to start with 'target', got %s", target.ID)
}
stored, ok := targetRepo.Targets[target.ID]
if !ok {
t.Fatalf("target not stored in repo")
}
if stored.Name != "New Target" {
t.Errorf("expected name 'New Target', got %s", stored.Name)
}
// Verify timestamps are set
if target.CreatedAt.IsZero() || target.UpdatedAt.IsZero() {
t.Errorf("expected timestamps to be set, CreatedAt=%v, UpdatedAt=%v", target.CreatedAt, target.UpdatedAt)
}
// Verify audit event
if len(auditRepo.Events) == 0 {
t.Fatalf("expected audit event, got none")
}
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
if lastEvent.Action != "create_target" {
t.Errorf("expected action 'create_target', got %s", lastEvent.Action)
}
if lastEvent.Actor != "test-actor" {
t.Errorf("expected actor 'test-actor', got %s", lastEvent.Actor)
}
}
func TestTargetService_Create_MissingName(t *testing.T) {
svc, _, _ := newTestTargetService()
ctx := context.Background()
target := &domain.DeploymentTarget{
Type: domain.TargetTypeNGINX,
}
err := svc.Create(ctx, target, "test-actor")
if err == nil {
t.Fatalf("expected error for missing name, got nil")
}
}
func TestTargetService_Create_RepoError(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
ctx := context.Background()
targetRepo.CreateErr = errNotFound
target := &domain.DeploymentTarget{
Name: "New Target",
Type: domain.TargetTypeNGINX,
}
err := svc.Create(ctx, target, "test-actor")
if err == nil {
t.Fatalf("expected error from repo, got nil")
}
}
func TestTargetService_Update_Success(t *testing.T) {
svc, targetRepo, auditRepo := newTestTargetService()
ctx := context.Background()
// Create initial target
existing := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX}
targetRepo.AddTarget(existing)
// Update it
updated := &domain.DeploymentTarget{
Name: "New Name",
Type: domain.TargetTypeApache,
}
err := svc.Update(ctx, "t-1", updated, "test-actor")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify update
stored := targetRepo.Targets["t-1"]
if stored.Name != "New Name" {
t.Errorf("expected name 'New Name', got %s", stored.Name)
}
// Verify audit event
if len(auditRepo.Events) == 0 {
t.Fatalf("expected audit event, got none")
}
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
if lastEvent.Action != "update_target" {
t.Errorf("expected action 'update_target', got %s", lastEvent.Action)
}
}
func TestTargetService_Update_MissingName(t *testing.T) {
svc, _, _ := newTestTargetService()
ctx := context.Background()
target := &domain.DeploymentTarget{
Type: domain.TargetTypeNGINX,
}
err := svc.Update(ctx, "t-1", target, "test-actor")
if err == nil {
t.Fatalf("expected error for missing name, got nil")
}
}
func TestTargetService_Delete_Success(t *testing.T) {
svc, targetRepo, auditRepo := newTestTargetService()
ctx := context.Background()
// Create initial target
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX}
targetRepo.AddTarget(target)
// Delete it
err := svc.Delete(ctx, "t-1", "test-actor")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify deletion
if _, ok := targetRepo.Targets["t-1"]; ok {
t.Errorf("target should be deleted from repo")
}
// Verify audit event
if len(auditRepo.Events) == 0 {
t.Fatalf("expected audit event, got none")
}
lastEvent := auditRepo.Events[len(auditRepo.Events)-1]
if lastEvent.Action != "delete_target" {
t.Errorf("expected action 'delete_target', got %s", lastEvent.Action)
}
}
func TestTargetService_Delete_RepoError(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
ctx := context.Background()
targetRepo.DeleteErr = errNotFound
err := svc.Delete(ctx, "t-1", "test-actor")
if err == nil {
t.Fatalf("expected error from repo, got nil")
}
}
func TestTargetService_ListTargets_Success(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
// Add targets
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
target2 := &domain.DeploymentTarget{ID: "t-2", Name: "Target 2", Type: domain.TargetTypeApache}
targetRepo.AddTarget(target1)
targetRepo.AddTarget(target2)
// Call handler-interface method
targets, total, err := svc.ListTargets(1, 50)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(targets) != 2 {
t.Errorf("expected 2 targets, got %d", len(targets))
}
if total != 2 {
t.Errorf("expected total=2, got %d", total)
}
}
func TestTargetService_GetTarget_Success(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
targetRepo.AddTarget(target)
result, err := svc.GetTarget("t-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ID != "t-1" || result.Name != "Target 1" {
t.Errorf("expected target t-1/Target 1, got %s/%s", result.ID, result.Name)
}
}
func TestTargetService_CreateTarget_Success(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
target := domain.DeploymentTarget{
Name: "New Target",
Type: domain.TargetTypeNGINX,
}
result, err := svc.CreateTarget(target)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ID == "" || len(result.ID) < 7 || result.ID[:6] != "target" {
t.Errorf("expected ID to start with 'target', got %s", result.ID)
}
// Verify it was stored
if _, ok := targetRepo.Targets[result.ID]; !ok {
t.Fatalf("target not stored in repo")
}
}
func TestTargetService_UpdateTarget_Success(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
// Create initial target
target := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX}
targetRepo.AddTarget(target)
// Update it
updated := domain.DeploymentTarget{
Name: "New Name",
Type: domain.TargetTypeApache,
}
result, err := svc.UpdateTarget("t-1", updated)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Name != "New Name" {
t.Errorf("expected name 'New Name', got %s", result.Name)
}
}
func TestTargetService_DeleteTarget_Success(t *testing.T) {
svc, targetRepo, _ := newTestTargetService()
// Create initial target
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX}
targetRepo.AddTarget(target)
// Delete it
err := svc.DeleteTarget("t-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify deletion
if _, ok := targetRepo.Targets["t-1"]; ok {
t.Errorf("target should be deleted from repo")
}
}
+92 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"sync"
"time"
"github.com/shankar0123/certctl/internal/domain"
@@ -117,6 +118,7 @@ func (m *mockCertRepo) AddCert(cert *domain.ManagedCertificate) {
// mockJobRepo is a test implementation of JobRepository
type mockJobRepo struct {
mu sync.Mutex
Jobs map[string]*domain.Job
StatusUpdates map[string]domain.JobStatus
CreateErr error
@@ -129,6 +131,8 @@ type mockJobRepo struct {
}
func (m *mockJobRepo) List(ctx context.Context) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
@@ -140,6 +144,8 @@ func (m *mockJobRepo) List(ctx context.Context) ([]*domain.Job, error) {
}
func (m *mockJobRepo) Get(ctx context.Context, id string) (*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.GetErr != nil {
return nil, m.GetErr
}
@@ -151,6 +157,8 @@ func (m *mockJobRepo) Get(ctx context.Context, id string) (*domain.Job, error) {
}
func (m *mockJobRepo) Create(ctx context.Context, job *domain.Job) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return m.CreateErr
}
@@ -159,6 +167,8 @@ func (m *mockJobRepo) Create(ctx context.Context, job *domain.Job) error {
}
func (m *mockJobRepo) Update(ctx context.Context, job *domain.Job) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateErr != nil {
return m.UpdateErr
}
@@ -167,6 +177,8 @@ func (m *mockJobRepo) Update(ctx context.Context, job *domain.Job) error {
}
func (m *mockJobRepo) Delete(ctx context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.DeleteErr != nil {
return m.DeleteErr
}
@@ -175,6 +187,8 @@ func (m *mockJobRepo) Delete(ctx context.Context, id string) error {
}
func (m *mockJobRepo) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListByStatusErr != nil {
return nil, m.ListByStatusErr
}
@@ -188,6 +202,8 @@ func (m *mockJobRepo) ListByStatus(ctx context.Context, status domain.JobStatus)
}
func (m *mockJobRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
var jobs []*domain.Job
for _, j := range m.Jobs {
if j.CertificateID == certID {
@@ -198,6 +214,8 @@ func (m *mockJobRepo) ListByCertificate(ctx context.Context, certID string) ([]*
}
func (m *mockJobRepo) UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateStatusErr != nil {
return m.UpdateStatusErr
}
@@ -214,6 +232,8 @@ func (m *mockJobRepo) UpdateStatus(ctx context.Context, id string, status domain
}
func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
var jobs []*domain.Job
for _, j := range m.Jobs {
if j.Type == jobType && j.Status == domain.JobStatusPending {
@@ -224,11 +244,14 @@ func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType
}
func (m *mockJobRepo) AddJob(job *domain.Job) {
m.mu.Lock()
defer m.mu.Unlock()
m.Jobs[job.ID] = job
}
// mockNotifRepo is a test implementation of NotificationRepository
type mockNotifRepo struct {
mu sync.Mutex
Notifications []*domain.NotificationEvent
CreateErr error
ListErr error
@@ -236,6 +259,8 @@ type mockNotifRepo struct {
}
func (m *mockNotifRepo) Create(ctx context.Context, notif *domain.NotificationEvent) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return m.CreateErr
}
@@ -244,6 +269,8 @@ func (m *mockNotifRepo) Create(ctx context.Context, notif *domain.NotificationEv
}
func (m *mockNotifRepo) List(ctx context.Context, filter *repository.NotificationFilter) ([]*domain.NotificationEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
@@ -251,6 +278,8 @@ func (m *mockNotifRepo) List(ctx context.Context, filter *repository.Notificatio
}
func (m *mockNotifRepo) UpdateStatus(ctx context.Context, id string, status string, sentAt time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateErr != nil {
return m.UpdateErr
}
@@ -264,17 +293,22 @@ func (m *mockNotifRepo) UpdateStatus(ctx context.Context, id string, status stri
}
func (m *mockNotifRepo) AddNotification(notif *domain.NotificationEvent) {
m.mu.Lock()
defer m.mu.Unlock()
m.Notifications = append(m.Notifications, notif)
}
// mockAuditRepo is a test implementation of AuditRepository
type mockAuditRepo struct {
mu sync.Mutex
Events []*domain.AuditEvent
CreateErr error
ListErr error
}
func (m *mockAuditRepo) Create(ctx context.Context, event *domain.AuditEvent) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return m.CreateErr
}
@@ -283,6 +317,8 @@ func (m *mockAuditRepo) Create(ctx context.Context, event *domain.AuditEvent) er
}
func (m *mockAuditRepo) List(ctx context.Context, filter *repository.AuditFilter) ([]*domain.AuditEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
@@ -312,6 +348,8 @@ func (m *mockAuditRepo) List(ctx context.Context, filter *repository.AuditFilter
}
func (m *mockAuditRepo) AddEvent(event *domain.AuditEvent) {
m.mu.Lock()
defer m.mu.Unlock()
m.Events = append(m.Events, event)
}
@@ -428,6 +466,7 @@ func (m *mockRenewalPolicyRepo) AddPolicy(policy *domain.RenewalPolicy) {
// mockAgentRepo is a test implementation of AgentRepository
type mockAgentRepo struct {
mu sync.Mutex
Agents map[string]*domain.Agent
HeartbeatUpdates map[string]time.Time
CreateErr error
@@ -440,6 +479,8 @@ type mockAgentRepo struct {
}
func (m *mockAgentRepo) List(ctx context.Context) ([]*domain.Agent, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
@@ -451,6 +492,8 @@ func (m *mockAgentRepo) List(ctx context.Context) ([]*domain.Agent, error) {
}
func (m *mockAgentRepo) Get(ctx context.Context, id string) (*domain.Agent, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.GetErr != nil {
return nil, m.GetErr
}
@@ -462,6 +505,8 @@ func (m *mockAgentRepo) Get(ctx context.Context, id string) (*domain.Agent, erro
}
func (m *mockAgentRepo) Create(ctx context.Context, agent *domain.Agent) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return m.CreateErr
}
@@ -470,6 +515,8 @@ func (m *mockAgentRepo) Create(ctx context.Context, agent *domain.Agent) error {
}
func (m *mockAgentRepo) Update(ctx context.Context, agent *domain.Agent) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateErr != nil {
return m.UpdateErr
}
@@ -478,6 +525,8 @@ func (m *mockAgentRepo) Update(ctx context.Context, agent *domain.Agent) error {
}
func (m *mockAgentRepo) Delete(ctx context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.DeleteErr != nil {
return m.DeleteErr
}
@@ -486,6 +535,8 @@ func (m *mockAgentRepo) Delete(ctx context.Context, id string) error {
}
func (m *mockAgentRepo) UpdateHeartbeat(ctx context.Context, id string, metadata *domain.AgentMetadata) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateHeartbeatErr != nil {
return m.UpdateHeartbeatErr
}
@@ -500,6 +551,8 @@ func (m *mockAgentRepo) UpdateHeartbeat(ctx context.Context, id string, metadata
}
func (m *mockAgentRepo) GetByAPIKey(ctx context.Context, keyHash string) (*domain.Agent, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.GetByAPIKeyErr != nil {
return nil, m.GetByAPIKeyErr
}
@@ -512,11 +565,14 @@ func (m *mockAgentRepo) GetByAPIKey(ctx context.Context, keyHash string) (*domai
}
func (m *mockAgentRepo) AddAgent(agent *domain.Agent) {
m.mu.Lock()
defer m.mu.Unlock()
m.Agents[agent.ID] = agent
}
// mockTargetRepo is a test implementation of TargetRepository
type mockTargetRepo struct {
mu sync.Mutex
Targets map[string]*domain.DeploymentTarget
CreateErr error
UpdateErr error
@@ -527,6 +583,8 @@ type mockTargetRepo struct {
}
func (m *mockTargetRepo) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
@@ -538,6 +596,8 @@ func (m *mockTargetRepo) List(ctx context.Context) ([]*domain.DeploymentTarget,
}
func (m *mockTargetRepo) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.GetErr != nil {
return nil, m.GetErr
}
@@ -549,6 +609,8 @@ func (m *mockTargetRepo) Get(ctx context.Context, id string) (*domain.Deployment
}
func (m *mockTargetRepo) Create(ctx context.Context, target *domain.DeploymentTarget) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.CreateErr != nil {
return m.CreateErr
}
@@ -557,6 +619,8 @@ func (m *mockTargetRepo) Create(ctx context.Context, target *domain.DeploymentTa
}
func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTarget) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.UpdateErr != nil {
return m.UpdateErr
}
@@ -565,6 +629,8 @@ func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTa
}
func (m *mockTargetRepo) Delete(ctx context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.DeleteErr != nil {
return m.DeleteErr
}
@@ -573,13 +639,22 @@ func (m *mockTargetRepo) Delete(ctx context.Context, id string) error {
}
func (m *mockTargetRepo) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListByCertErr != nil {
return nil, m.ListByCertErr
}
return m.List(ctx)
// Don't call List again to avoid double-locking
var targets []*domain.DeploymentTarget
for _, t := range m.Targets {
targets = append(targets, t)
}
return targets, nil
}
func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
m.mu.Lock()
defer m.mu.Unlock()
m.Targets[target.ID] = target
}
@@ -641,6 +716,17 @@ func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error)
return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil
}
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
if m.Err != nil {
return nil, m.Err
}
now := time.Now()
return &RenewalInfoResult{
SuggestedWindowStart: now,
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour),
}, nil
}
// Constructor functions for mocks
func newMockCertificateRepository() *mockCertRepo {
@@ -820,6 +906,7 @@ func newMockRevocationRepository() *mockRevocationRepo {
// mockNotifier is a simple notifier for testing
type mockNotifier struct {
mu sync.Mutex
messages []*mockNotifierMessage
SendErr error
}
@@ -837,6 +924,8 @@ func newMockNotifier() *mockNotifier {
}
func (m *mockNotifier) Send(ctx context.Context, recipient string, subject string, body string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.SendErr != nil {
return m.SendErr
}
@@ -853,6 +942,8 @@ func (m *mockNotifier) Channel() string {
}
func (m *mockNotifier) getSentCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.messages)
}
+751
View File
@@ -0,0 +1,751 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
setApiKey,
getCertificates,
getCertificate,
createCertificate,
triggerRenewal,
revokeCertificate,
exportCertificatePEM,
downloadCertificatePEM,
exportCertificatePKCS12,
getAgents,
getAgent,
registerAgent,
getJobs,
cancelJob,
approveRenewal,
rejectRenewal,
getNotifications,
getAuditEvents,
getPolicies,
getIssuers,
getTargets,
getDiscoveredCertificates,
getDiscoveredCertificate,
claimDiscoveredCertificate,
dismissDiscoveredCertificate,
getNetworkScanTargets,
getNetworkScanTarget,
createNetworkScanTarget,
triggerNetworkScan,
getDashboardSummary,
getMetrics,
} from './client';
// Mock global fetch
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
function mockJsonResponse(data: unknown, status = 200) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(data),
statusText: 'OK',
} as Response);
}
function mockBlobResponse(status = 200) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
blob: () => Promise.resolve(new Blob(['test data'])),
statusText: 'OK',
} as Response);
}
function mockErrorResponse(status: number, body: { message?: string; error?: string } = {}) {
return Promise.resolve({
ok: false,
status,
json: () => Promise.resolve(body),
statusText: 'Error',
} as Response);
}
function mockNetworkError() {
return Promise.reject(new TypeError('Failed to fetch'));
}
describe('API Client - Error Handling', () => {
beforeEach(() => {
mockFetch.mockReset();
setApiKey(null);
});
// ─── Certificate Endpoints (Network Errors) ──────────────
describe('Certificate endpoints - Network errors', () => {
it('getCertificates propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getCertificates()).rejects.toThrow('Failed to fetch');
});
it('getCertificate propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getCertificate('mc-test')).rejects.toThrow('Failed to fetch');
});
it('createCertificate propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(createCertificate({ common_name: 'test.com' })).rejects.toThrow(
'Failed to fetch',
);
});
it('triggerRenewal propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(triggerRenewal('mc-test')).rejects.toThrow('Failed to fetch');
});
it('revokeCertificate propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(revokeCertificate('mc-test', 'keyCompromise')).rejects.toThrow(
'Failed to fetch',
);
});
it('exportCertificatePEM propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(exportCertificatePEM('mc-test')).rejects.toThrow('Failed to fetch');
});
it('downloadCertificatePEM propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(downloadCertificatePEM('mc-test')).rejects.toThrow('Failed to fetch');
});
it('exportCertificatePKCS12 propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(exportCertificatePKCS12('mc-test', 'password')).rejects.toThrow(
'Failed to fetch',
);
});
});
// ─── Certificate Endpoints (HTTP Errors) ─────────────────
describe('Certificate endpoints - HTTP error responses', () => {
it('getCertificates with 401 throws Authentication required', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getCertificates()).rejects.toThrow('Authentication required');
});
it('getCertificates with 403 throws Forbidden', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(403, { message: 'Access denied' }));
await expect(getCertificates()).rejects.toThrow('Access denied');
});
it('getCertificate with 404 throws not found message', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(404, { message: 'Certificate not found' }));
await expect(getCertificate('mc-nonexistent')).rejects.toThrow(
'Certificate not found',
);
});
it('createCertificate with 400 throws validation error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(400, { message: 'Invalid common name' }),
);
await expect(createCertificate({ common_name: 'invalid' })).rejects.toThrow(
'Invalid common name',
);
});
it('triggerRenewal with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Internal server error' }),
);
await expect(triggerRenewal('mc-test')).rejects.toThrow('Internal server error');
});
it('revokeCertificate with 429 throws rate limit error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(429, { message: 'Rate limit exceeded' }),
);
await expect(revokeCertificate('mc-test', 'keyCompromise')).rejects.toThrow(
'Rate limit exceeded',
);
});
it('downloadCertificatePEM with 404 throws Export failed', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(404));
await expect(downloadCertificatePEM('mc-nonexistent')).rejects.toThrow(
'Export failed',
);
});
it('exportCertificatePKCS12 with 403 throws Export failed', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(403));
await expect(exportCertificatePKCS12('mc-test', 'password')).rejects.toThrow(
'Export failed',
);
});
it('getCertificates falls back to statusText when no message', async () => {
const response = Promise.resolve({
ok: false,
status: 502,
json: () => Promise.reject(new Error('not json')),
statusText: 'Bad Gateway',
} as Response);
mockFetch.mockReturnValueOnce(response);
await expect(getCertificates()).rejects.toThrow('Bad Gateway');
});
});
// ─── Certificate Endpoints (Malformed Responses) ─────────
describe('Certificate endpoints - Malformed responses', () => {
it('getCertificates with invalid JSON throws parse error', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError('Unexpected token')),
} as Response),
);
await expect(getCertificates()).rejects.toThrow();
});
it('getCertificate with empty response body', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 204,
json: () => Promise.resolve({}),
} as Response),
);
const result = await getCertificate('mc-test');
expect(result).toEqual({});
});
});
// ─── Agent Endpoints (Network Errors) ─────────────────────
describe('Agent endpoints - Network errors', () => {
it('getAgents propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getAgents()).rejects.toThrow('Failed to fetch');
});
it('getAgent propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getAgent('a-web01')).rejects.toThrow('Failed to fetch');
});
it('registerAgent propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(registerAgent({ name: 'agent1' })).rejects.toThrow('Failed to fetch');
});
});
// ─── Agent Endpoints (HTTP Errors) ─────────────────────────
describe('Agent endpoints - HTTP error responses', () => {
it('getAgents with 401 triggers auth-required event', async () => {
const listener = vi.fn();
window.addEventListener('certctl:auth-required', listener);
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getAgents()).rejects.toThrow('Authentication required');
expect(listener).toHaveBeenCalled();
window.removeEventListener('certctl:auth-required', listener);
});
it('getAgent with 404 throws not found', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(404, { message: 'Agent not found' }));
await expect(getAgent('a-nonexistent')).rejects.toThrow('Agent not found');
});
it('registerAgent with 400 throws validation error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(400, { message: 'Invalid agent name' }),
);
await expect(registerAgent({ name: '' })).rejects.toThrow('Invalid agent name');
});
it('getAgents with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Database connection failed' }),
);
await expect(getAgents()).rejects.toThrow('Database connection failed');
});
});
// ─── Job Endpoints (Network Errors) ──────────────────────
describe('Job endpoints - Network errors', () => {
it('getJobs propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getJobs()).rejects.toThrow('Failed to fetch');
});
it('cancelJob propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(cancelJob('job-123')).rejects.toThrow('Failed to fetch');
});
it('approveRenewal propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(approveRenewal('job-123')).rejects.toThrow('Failed to fetch');
});
it('rejectRenewal propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(rejectRenewal('job-123', 'Not ready')).rejects.toThrow('Failed to fetch');
});
});
// ─── Job Endpoints (HTTP Errors) ─────────────────────────
describe('Job endpoints - HTTP error responses', () => {
it('getJobs with 401 throws Authentication required', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getJobs()).rejects.toThrow('Authentication required');
});
it('cancelJob with 400 throws invalid state error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(400, { message: 'Cannot cancel completed job' }),
);
await expect(cancelJob('job-123')).rejects.toThrow('Cannot cancel completed job');
});
it('approveRenewal with 403 throws Forbidden', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(403, { message: 'Permission denied' }));
await expect(approveRenewal('job-123')).rejects.toThrow('Permission denied');
});
it('rejectRenewal with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Failed to process rejection' }),
);
await expect(rejectRenewal('job-123', 'Too risky')).rejects.toThrow(
'Failed to process rejection',
);
});
it('getJobs with 429 throws rate limit error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(429, { message: 'Too many requests' }),
);
await expect(getJobs()).rejects.toThrow('Too many requests');
});
});
// ─── Notification Endpoints (Network Errors) ─────────────
describe('Notification endpoints - Network errors', () => {
it('getNotifications propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getNotifications()).rejects.toThrow('Failed to fetch');
});
});
// ─── Notification Endpoints (HTTP Errors) ────────────────
describe('Notification endpoints - HTTP error responses', () => {
it('getNotifications with 401 throws Authentication required', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getNotifications()).rejects.toThrow('Authentication required');
});
it('getNotifications with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Cache unavailable' }),
);
await expect(getNotifications()).rejects.toThrow('Cache unavailable');
});
});
// ─── Audit Endpoints (Network Errors) ────────────────────
describe('Audit endpoints - Network errors', () => {
it('getAuditEvents propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getAuditEvents()).rejects.toThrow('Failed to fetch');
});
});
// ─── Audit Endpoints (HTTP Errors) ───────────────────────
describe('Audit endpoints - HTTP error responses', () => {
it('getAuditEvents with 403 throws Forbidden', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(403, { message: 'Audit access denied' }));
await expect(getAuditEvents()).rejects.toThrow('Audit access denied');
});
it('getAuditEvents with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Audit log unavailable' }),
);
await expect(getAuditEvents()).rejects.toThrow('Audit log unavailable');
});
});
// ─── Policy Endpoints (Network Errors) ───────────────────
describe('Policy endpoints - Network errors', () => {
it('getPolicies propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getPolicies()).rejects.toThrow('Failed to fetch');
});
});
// ─── Policy Endpoints (HTTP Errors) ──────────────────────
describe('Policy endpoints - HTTP error responses', () => {
it('getPolicies with 401 throws Authentication required', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getPolicies()).rejects.toThrow('Authentication required');
});
it('getPolicies with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Policy service error' }),
);
await expect(getPolicies()).rejects.toThrow('Policy service error');
});
});
// ─── Issuer Endpoints (Network Errors) ───────────────────
describe('Issuer endpoints - Network errors', () => {
it('getIssuers propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getIssuers()).rejects.toThrow('Failed to fetch');
});
});
// ─── Issuer Endpoints (HTTP Errors) ──────────────────────
describe('Issuer endpoints - HTTP error responses', () => {
it('getIssuers with 401 throws Authentication required', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getIssuers()).rejects.toThrow('Authentication required');
});
it('getIssuers with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Issuer registry error' }),
);
await expect(getIssuers()).rejects.toThrow('Issuer registry error');
});
});
// ─── Target Endpoints (Network Errors) ───────────────────
describe('Target endpoints - Network errors', () => {
it('getTargets propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getTargets()).rejects.toThrow('Failed to fetch');
});
});
// ─── Target Endpoints (HTTP Errors) ──────────────────────
describe('Target endpoints - HTTP error responses', () => {
it('getTargets with 401 throws Authentication required', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getTargets()).rejects.toThrow('Authentication required');
});
it('getTargets with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Target registry error' }),
);
await expect(getTargets()).rejects.toThrow('Target registry error');
});
});
// ─── Discovery Endpoints (Network Errors) ────────────────
describe('Discovery endpoints - Network errors', () => {
it('getDiscoveredCertificates propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getDiscoveredCertificates()).rejects.toThrow('Failed to fetch');
});
it('getDiscoveredCertificate propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getDiscoveredCertificate('disc-123')).rejects.toThrow('Failed to fetch');
});
it('claimDiscoveredCertificate propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(claimDiscoveredCertificate('disc-123', 'mc-test')).rejects.toThrow(
'Failed to fetch',
);
});
it('dismissDiscoveredCertificate propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(dismissDiscoveredCertificate('disc-123')).rejects.toThrow(
'Failed to fetch',
);
});
});
// ─── Discovery Endpoints (HTTP Errors) ───────────────────
describe('Discovery endpoints - HTTP error responses', () => {
it('getDiscoveredCertificates with 401 throws Authentication required', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getDiscoveredCertificates()).rejects.toThrow('Authentication required');
});
it('getDiscoveredCertificate with 404 throws not found', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(404, { message: 'Discovered certificate not found' }),
);
await expect(getDiscoveredCertificate('disc-nonexistent')).rejects.toThrow(
'Discovered certificate not found',
);
});
it('claimDiscoveredCertificate with 400 throws validation error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(400, { message: 'Certificate already claimed' }),
);
await expect(claimDiscoveredCertificate('disc-123', 'mc-test')).rejects.toThrow(
'Certificate already claimed',
);
});
it('dismissDiscoveredCertificate with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Discovery service error' }),
);
await expect(dismissDiscoveredCertificate('disc-123')).rejects.toThrow(
'Discovery service error',
);
});
it('getDiscoveredCertificates with 429 throws rate limit error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(429, { message: 'Rate limit exceeded' }),
);
await expect(getDiscoveredCertificates()).rejects.toThrow('Rate limit exceeded');
});
});
// ─── Network Scan Endpoints (Network Errors) ─────────────
describe('Network scan endpoints - Network errors', () => {
it('getNetworkScanTargets propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getNetworkScanTargets()).rejects.toThrow('Failed to fetch');
});
it('getNetworkScanTarget propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getNetworkScanTarget('scan-123')).rejects.toThrow('Failed to fetch');
});
it('createNetworkScanTarget propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(
createNetworkScanTarget({ name: 'test', cidrs: ['10.0.0.0/24'] }),
).rejects.toThrow('Failed to fetch');
});
it('triggerNetworkScan propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(triggerNetworkScan('scan-123')).rejects.toThrow('Failed to fetch');
});
});
// ─── Network Scan Endpoints (HTTP Errors) ────────────────
describe('Network scan endpoints - HTTP error responses', () => {
it('getNetworkScanTargets with 401 throws Authentication required', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getNetworkScanTargets()).rejects.toThrow('Authentication required');
});
it('getNetworkScanTarget with 404 throws not found', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(404, { message: 'Scan target not found' }),
);
await expect(getNetworkScanTarget('scan-nonexistent')).rejects.toThrow(
'Scan target not found',
);
});
it('createNetworkScanTarget with 400 throws validation error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(400, { message: 'Invalid CIDR range' }),
);
await expect(
createNetworkScanTarget({ name: 'test', cidrs: ['invalid'] }),
).rejects.toThrow('Invalid CIDR range');
});
it('triggerNetworkScan with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Scanner unavailable' }),
);
await expect(triggerNetworkScan('scan-123')).rejects.toThrow('Scanner unavailable');
});
it('getNetworkScanTargets with 429 throws rate limit error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(429, { message: 'Scan quota exceeded' }),
);
await expect(getNetworkScanTargets()).rejects.toThrow('Scan quota exceeded');
});
});
// ─── Stats/Metrics Endpoints (Network Errors) ────────────
describe('Stats/Metrics endpoints - Network errors', () => {
it('getDashboardSummary propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getDashboardSummary()).rejects.toThrow('Failed to fetch');
});
it('getMetrics propagates network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(getMetrics()).rejects.toThrow('Failed to fetch');
});
});
// ─── Stats/Metrics Endpoints (HTTP Errors) ───────────────
describe('Stats/Metrics endpoints - HTTP error responses', () => {
it('getDashboardSummary with 401 throws Authentication required', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getDashboardSummary()).rejects.toThrow('Authentication required');
});
it('getDashboardSummary with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Stats aggregation failed' }),
);
await expect(getDashboardSummary()).rejects.toThrow('Stats aggregation failed');
});
it('getMetrics with 401 throws Authentication required', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getMetrics()).rejects.toThrow('Authentication required');
});
it('getMetrics with 500 throws server error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Metrics service error' }),
);
await expect(getMetrics()).rejects.toThrow('Metrics service error');
});
it('getDashboardSummary with 429 throws rate limit error', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(429, { message: 'Metrics rate limit exceeded' }),
);
await expect(getDashboardSummary()).rejects.toThrow('Metrics rate limit exceeded');
});
});
// ─── Cross-Cutting Error Handling ────────────────────────
describe('Cross-cutting error scenarios', () => {
it('401 on any endpoint triggers auth-required event once', async () => {
const listener = vi.fn();
window.addEventListener('certctl:auth-required', listener);
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getCertificates()).rejects.toThrow('Authentication required');
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getAgents()).rejects.toThrow('Authentication required');
expect(listener).toHaveBeenCalledTimes(2);
window.removeEventListener('certctl:auth-required', listener);
});
it('prefers message field over error field', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(400, {
message: 'Validation failed',
error: 'Fallback error',
}),
);
await expect(getCertificates()).rejects.toThrow('Validation failed');
});
it('uses error field when message unavailable', async () => {
mockFetch.mockReturnValueOnce(
mockErrorResponse(500, { error: 'Only error field present' }),
);
await expect(getCertificates()).rejects.toThrow('Only error field present');
});
it('falls back to statusText when both fields missing', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 418,
json: () => Promise.resolve({}),
statusText: "I'm a teapot",
} as Response),
);
await expect(getCertificates()).rejects.toThrow("I'm a teapot");
});
it('preserves error context through async chain', async () => {
const err = new Error('Original error');
mockFetch.mockReturnValueOnce(Promise.reject(err));
await expect(getCertificates()).rejects.toBe(err);
});
it('handles multiple sequential errors correctly', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(500, { error: 'Error 1' }));
await expect(getCertificates()).rejects.toThrow('Error 1');
mockFetch.mockReturnValueOnce(mockErrorResponse(500, { error: 'Error 2' }));
await expect(getAgents()).rejects.toThrow('Error 2');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
// ─── Binary Response Handling (Export) ────────────────────
describe('Binary response error handling', () => {
it('downloadCertificatePEM with network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(downloadCertificatePEM('mc-test')).rejects.toThrow('Failed to fetch');
});
it('downloadCertificatePEM with server error', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(500));
await expect(downloadCertificatePEM('mc-test')).rejects.toThrow('Export failed');
});
it('exportCertificatePKCS12 with network error', async () => {
mockFetch.mockReturnValueOnce(mockNetworkError());
await expect(exportCertificatePKCS12('mc-test', 'pass')).rejects.toThrow(
'Failed to fetch',
);
});
it('exportCertificatePKCS12 with server error', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(403));
await expect(exportCertificatePKCS12('mc-test', 'pass')).rejects.toThrow(
'Export failed',
);
});
it('downloadCertificatePEM uses Authorization header on error', async () => {
setApiKey('test-key');
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
try {
await downloadCertificatePEM('mc-test');
} catch {
// Expected to fail
}
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBe('Bearer test-key');
});
});
});
+128
View File
@@ -76,6 +76,8 @@ import {
updateNetworkScanTarget,
deleteNetworkScanTarget,
triggerNetworkScan,
previewDigest,
sendDigest,
} from './client';
// Mock global fetch
@@ -878,4 +880,130 @@ describe('API Client', () => {
expect(body.password).toBe('');
});
});
// ─── Profile (EKU / S/MIME) ─────────────────────────────
describe('Profile for EKU Display', () => {
it('getProfile fetches profile by ID with EKU data', async () => {
const profileData = {
id: 'prof-smime',
name: 'S/MIME Email',
allowed_ekus: ['emailProtection'],
max_ttl_seconds: 31536000,
enabled: true,
};
mockFetch.mockReturnValueOnce(mockJsonResponse(profileData));
const result = await getProfile('prof-smime');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/profiles/prof-smime');
expect(result.allowed_ekus).toEqual(['emailProtection']);
});
it('getProfile returns profile with multiple EKUs', async () => {
const profileData = {
id: 'prof-tls',
name: 'TLS Server',
allowed_ekus: ['serverAuth', 'clientAuth'],
max_ttl_seconds: 7776000,
enabled: true,
};
mockFetch.mockReturnValueOnce(mockJsonResponse(profileData));
const result = await getProfile('prof-tls');
expect(result.allowed_ekus).toHaveLength(2);
expect(result.allowed_ekus).toContain('serverAuth');
expect(result.allowed_ekus).toContain('clientAuth');
});
});
// ─── Job Verification Fields ─────────────────────────────
describe('Job Verification', () => {
it('getJobs returns jobs with verification fields', async () => {
const jobData = {
data: [{
id: 'job-1',
certificate_id: 'mc-1',
type: 'Deployment',
status: 'Completed',
verification_status: 'success',
verified_at: '2026-03-28T12:00:00Z',
verification_fingerprint: 'abc123',
verification_error: '',
attempts: 1,
max_attempts: 3,
scheduled_at: '2026-03-28T11:00:00Z',
completed_at: '2026-03-28T11:05:00Z',
created_at: '2026-03-28T11:00:00Z',
}],
total: 1,
page: 1,
per_page: 50,
};
mockFetch.mockReturnValueOnce(mockJsonResponse(jobData));
const result = await getJobs({ certificate_id: 'mc-1' });
expect(result.data[0].verification_status).toBe('success');
expect(result.data[0].verified_at).toBe('2026-03-28T12:00:00Z');
expect(result.data[0].verification_fingerprint).toBe('abc123');
});
it('getJobs handles jobs without verification data', async () => {
const jobData = {
data: [{
id: 'job-2',
certificate_id: 'mc-2',
type: 'Issuance',
status: 'Completed',
attempts: 1,
max_attempts: 3,
scheduled_at: '2026-03-28T11:00:00Z',
completed_at: '2026-03-28T11:05:00Z',
created_at: '2026-03-28T11:00:00Z',
}],
total: 1,
page: 1,
per_page: 50,
};
mockFetch.mockReturnValueOnce(mockJsonResponse(jobData));
const result = await getJobs({});
expect(result.data[0].verification_status).toBeUndefined();
expect(result.data[0].verified_at).toBeUndefined();
});
});
// ─── Digest ─────────────────────────────
describe('Digest', () => {
it('previewDigest fetches HTML preview', async () => {
const html = '<html><body>Digest Preview</body></html>';
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(html),
} as Response)
);
const result = await previewDigest();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/digest/preview');
expect(result).toBe(html);
});
it('previewDigest throws on error', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 503,
text: () => Promise.resolve('not configured'),
} as Response)
);
await expect(previewDigest()).rejects.toThrow('Digest preview failed: 503');
});
it('sendDigest sends POST request', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'digest sent' }));
const result = await sendDigest();
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/digest/send');
expect(init.method).toBe('POST');
expect(result.message).toBe('digest sent');
});
});
});
+14
View File
@@ -351,5 +351,19 @@ export const getIssuanceRate = (days = 30) =>
export const getMetrics = () =>
fetchJSON<MetricsResponse>(`${BASE}/metrics`);
// Digest
export const previewDigest = () => {
const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
return fetch(`${BASE}/digest/preview`, { headers })
.then(r => {
if (!r.ok) throw new Error(`Digest preview failed: ${r.status}`);
return r.text();
});
};
export const sendDigest = () =>
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
// Health
export const getHealth = () => fetchJSON<{ status: string }>('/health');
+4
View File
@@ -78,6 +78,10 @@ export interface Job {
started_at: string;
completed_at: string;
created_at: string;
verification_status?: string;
verified_at?: string;
verification_fingerprint?: string;
verification_error?: string;
}
export interface Notification {
+81 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
@@ -102,6 +102,28 @@ function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certI
return undefined;
};
// Verification step (M25: post-deployment TLS verification)
const getVerifiedStatus = () => {
if (!latestDeploy || latestDeploy.status !== 'Completed') return 'pending' as const;
if (latestDeploy.verification_status === 'success') return 'completed' as const;
if (latestDeploy.verification_status === 'failed') return 'failed' as const;
if (latestDeploy.verification_status === 'skipped') return 'completed' as const;
if (latestDeploy.verification_status === 'pending') return 'active' as const;
return 'pending' as const;
};
const getVerifiedTime = () => {
if (!latestDeploy || latestDeploy.status !== 'Completed') return undefined;
if (latestDeploy.verification_status === 'success' && latestDeploy.verified_at) {
return `Verified ${formatDateTime(latestDeploy.verified_at)}`;
}
if (latestDeploy.verification_status === 'failed') {
return latestDeploy.verification_error || 'Verification failed';
}
if (latestDeploy.verification_status === 'skipped') return 'Skipped (best-effort)';
if (latestDeploy.verification_status === 'pending') return 'Awaiting verification';
return undefined;
};
const getActiveStatus = () => {
if (certStatus === 'Active') return 'completed' as const;
if (certStatus === 'Revoked') return 'failed' as const;
@@ -116,6 +138,9 @@ function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certI
return undefined;
};
// Only show verification step if deployment has completed and verification data exists
const showVerificationStep = latestDeploy?.status === 'Completed' && latestDeploy?.verification_status;
return (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Lifecycle Timeline</h3>
@@ -123,6 +148,9 @@ function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certI
<TimelineStep label="Requested" status={getRequestedStatus()} time={getRequestedTime()} />
<TimelineStep label="Issued" status={getIssuedStatus()} time={getIssuedTime()} />
<TimelineStep label="Deploying" status={getDeployStatus()} time={getDeployTime()} />
{showVerificationStep && (
<TimelineStep label="Verified" status={getVerifiedStatus()} time={getVerifiedTime()} />
)}
<TimelineStep label={certStatus === 'Revoked' ? 'Revoked' : certStatus === 'Expired' ? 'Expired' : 'Active'}
status={getActiveStatus()} time={getActiveTime()} isLast />
</div>
@@ -248,6 +276,13 @@ export default function CertificateDetailPage() {
enabled: showDeploy,
});
// Fetch profile for EKU display (S/MIME, code signing badges)
const { data: profile } = useQuery({
queryKey: ['profile', cert?.certificate_profile_id],
queryFn: () => getProfile(cert!.certificate_profile_id),
enabled: !!cert?.certificate_profile_id,
});
const renewMutation = useMutation({
mutationFn: () => triggerRenewal(id!),
onSuccess: () => {
@@ -465,13 +500,57 @@ export default function CertificateDetailPage() {
<h3 className="text-sm font-semibold text-ink-muted mb-4">Certificate Details</h3>
<InfoRow label="Status" value={<StatusBadge status={cert.status} />} />
<InfoRow label="Common Name" value={cert.common_name} />
<InfoRow label="SANs" value={cert.sans?.length ? cert.sans.join(', ') : '—'} />
<InfoRow label="SANs" value={cert.sans?.length ? (
<span className="text-sm">
{cert.sans.map((san, i) => {
const isEmail = san.includes('@');
return (
<span key={san}>
{i > 0 && ', '}
{isEmail ? (
<span className="inline-flex items-center gap-1">
<span className="text-xs text-purple-600 bg-purple-50 px-1 rounded">email</span>
<span>{san}</span>
</span>
) : san}
</span>
);
})}
</span>
) : '—'} />
<InfoRow label="Serial Number" value={cert.serial_number || '—'} />
<InfoRow label="Fingerprint" value={
cert.fingerprint ? <span className="font-mono text-xs">{cert.fingerprint.slice(0, 24)}...</span> : '—'
} />
<InfoRow label="Key Algorithm" value={cert.key_algorithm || '—'} />
<InfoRow label="Key Size" value={cert.key_size ? `${cert.key_size} bits` : '—'} />
{profile?.allowed_ekus && profile.allowed_ekus.length > 0 && (
<InfoRow label="Extended Key Usage" value={
<div className="flex flex-wrap gap-1">
{profile.allowed_ekus.map(eku => {
const ekuStyles: Record<string, string> = {
serverAuth: 'bg-blue-50 text-blue-700',
clientAuth: 'bg-green-50 text-green-700',
emailProtection: 'bg-purple-50 text-purple-700',
codeSigning: 'bg-amber-50 text-amber-700',
timeStamping: 'bg-teal-50 text-teal-700',
};
const ekuLabels: Record<string, string> = {
serverAuth: 'TLS Server',
clientAuth: 'TLS Client',
emailProtection: 'S/MIME',
codeSigning: 'Code Signing',
timeStamping: 'Timestamping',
};
return (
<span key={eku} className={`text-xs px-1.5 py-0.5 rounded font-medium ${ekuStyles[eku] || 'bg-gray-50 text-gray-700'}`}>
{ekuLabels[eku] || eku}
</span>
);
})}
</div>
} />
)}
</div>
{/* Lifecycle */}
+89 -2
View File
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
@@ -7,7 +8,7 @@ import {
import {
getCertificates, getAgents, getJobs, getNotifications, getHealth,
getDashboardSummary, getCertificatesByStatus, getExpirationTimeline,
getJobTrends, getIssuanceRate,
getJobTrends, getIssuanceRate, previewDigest, sendDigest,
} from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
@@ -75,6 +76,89 @@ const CustomTooltip = ({ active, payload, label }: any) => {
);
};
function DigestCard() {
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
const [showPreview, setShowPreview] = useState(false);
const previewMutation = useMutation({
mutationFn: previewDigest,
onSuccess: (html) => {
setPreviewHtml(html);
setShowPreview(true);
},
});
const sendMutation = useMutation({ mutationFn: sendDigest });
return (
<>
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-ink-muted">Certificate Digest</h3>
<p className="text-xs text-ink-faint mt-0.5">Send an email summary of certificate status to configured recipients</p>
</div>
<div className="flex gap-2">
<button
onClick={() => previewMutation.mutate()}
disabled={previewMutation.isPending}
className="btn btn-secondary text-xs"
>
{previewMutation.isPending ? 'Loading...' : 'Preview'}
</button>
<button
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending}
className="btn btn-primary text-xs"
>
{sendMutation.isPending ? 'Sending...' : 'Send Now'}
</button>
</div>
</div>
{sendMutation.isSuccess && (
<div className="mt-3 text-xs text-emerald-600 bg-emerald-50 border border-emerald-200 rounded px-3 py-2">
Digest sent successfully.
</div>
)}
{sendMutation.isError && (
<div className="mt-3 text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
Failed to send digest. Check SMTP configuration.
</div>
)}
{previewMutation.isError && (
<div className="mt-3 text-xs text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
Digest not configured. Set CERTCTL_DIGEST_ENABLED=true and configure SMTP.
</div>
)}
</div>
{/* Preview Modal */}
{showPreview && previewHtml && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowPreview(false)}>
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-700">Digest Email Preview</h3>
<button onClick={() => setShowPreview(false)} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-y-auto max-h-[calc(80vh-52px)]">
<iframe
srcDoc={previewHtml}
title="Digest Preview"
className="w-full h-[600px] border-0"
sandbox=""
/>
</div>
</div>
</div>
)}
</>
);
}
export default function DashboardPage() {
const navigate = useNavigate();
@@ -293,6 +377,9 @@ export default function DashboardPage() {
</div>
</div>
{/* Certificate Digest */}
<DigestCard />
{/* Pending Jobs Banner */}
{pendingJobs > 0 && (
<div className="bg-brand-50 border border-brand-200 rounded px-5 py-4 flex items-center justify-between">
+18 -2
View File
@@ -11,16 +11,20 @@ import type { Target } from '../api/types';
const typeLabels: Record<string, string> = {
nginx: 'NGINX',
f5_bigip: 'F5 BIG-IP',
iis: 'IIS',
apache: 'Apache',
haproxy: 'HAProxy',
traefik: 'Traefik',
caddy: 'Caddy',
f5_bigip: 'F5 BIG-IP',
iis: 'IIS',
};
const TARGET_TYPES = [
{ value: 'nginx', label: 'NGINX', description: 'Deploy to NGINX web server via file write + config validation + reload' },
{ value: 'apache', label: 'Apache httpd', description: 'Separate cert/chain/key files, apachectl configtest, graceful reload' },
{ value: 'haproxy', label: 'HAProxy', description: 'Combined PEM file (cert+chain+key), optional validate, reload' },
{ value: 'traefik', label: 'Traefik', description: 'File provider deployment — writes cert/key to watched directory, auto-reload' },
{ value: 'caddy', label: 'Caddy', description: 'Admin API hot-reload or file-based deployment with configurable mode' },
{ value: 'f5_bigip', label: 'F5 BIG-IP', description: 'iControl REST via proxy agent (V3 implementation)' },
{ value: 'iis', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or proxy WinRM (V3 implementation)' },
];
@@ -43,6 +47,18 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'systemctl reload haproxy' },
{ key: 'validate_cmd', label: 'Validate Command (optional)', placeholder: 'haproxy -c -f /etc/haproxy/haproxy.cfg' },
],
traefik: [
{ key: 'cert_dir', label: 'Certificate Directory', placeholder: '/etc/traefik/certs', required: true },
{ key: 'cert_file', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
{ key: 'key_file', label: 'Key Filename', placeholder: 'key.pem (default)' },
],
caddy: [
{ key: 'mode', label: 'Deployment Mode', placeholder: 'api (default) or file', required: true },
{ key: 'admin_api', label: 'Admin API URL', placeholder: 'http://localhost:2019 (default)' },
{ key: 'cert_dir', label: 'Certificate Directory (file mode)', placeholder: '/etc/caddy/certs' },
{ key: 'cert_file', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
{ key: 'key_file', label: 'Key Filename', placeholder: 'key.pem (default)' },
],
f5_bigip: [
{ key: 'management_ip', label: 'Management IP', placeholder: '192.168.1.100', required: true },
{ key: 'partition', label: 'Partition', placeholder: 'Common' },