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>
This commit is contained in:
shankar0123
2026-03-28 21:18:35 -04:00
parent cb2ef9d0e7
commit ec21c9bb29
61 changed files with 6106 additions and 27 deletions
+17
View File
@@ -125,3 +125,20 @@ jobs:
- name: Build Frontend - name: Build Frontend
working-directory: web working-directory: web
run: npx vite build 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
+20 -7
View File
@@ -55,8 +55,8 @@ 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: certctl gives you a single pane of glass for every TLS certificate in your organization:
- **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 - **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** — 95 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation, with sparse fields, sort, cursor pagination, and time-range filters - **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) - **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 - **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 - **Certificate export** — PEM (JSON or file download) and PKCS#12 formats, with audit trail; private keys never included
@@ -64,7 +64,10 @@ certctl gives you a single pane of glass for every TLS certificate in your organ
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol - **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 - **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 - **Approval workflows** — require human sign-off on renewals before deployment
- **Background scheduler** — 6 automated loops: renewal checks, job processing, agent health, notifications, short-lived cert expiry, and network scanning - **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). For the full capability breakdown — revocation infrastructure, policy engine, observability, EST enrollment, and more — see the [Feature Inventory](docs/features.md).
@@ -357,7 +360,7 @@ make docker-clean # Stop + remove volumes
## API Overview ## 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 ### Key Endpoints
``` ```
@@ -392,6 +395,10 @@ GET /api/v1/jobs/{id}/verification Get verification status
GET /api/v1/metrics/prometheus Prometheus exposition format GET /api/v1/metrics/prometheus Prometheus exposition format
GET /api/v1/stats/summary Dashboard summary 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) # EST enrollment (RFC 7030)
POST /.well-known/est/simpleenroll Device certificate enrollment POST /.well-known/est/simpleenroll Device certificate enrollment
GET /.well-known/est/cacerts CA certificate chain (PKCS#7) GET /.well-known/est/cacerts CA certificate chain (PKCS#7)
@@ -466,11 +473,11 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
### V2: Operational Maturity ### 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 ✅):** **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 - **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 - **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 - **GUI Operations** — bulk renew/revoke/reassign, deployment timeline, inline policy editor, target wizard, audit export (CSV/JSON), short-lived credentials view
@@ -479,7 +486,7 @@ 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 - **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 - **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 - **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 - **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 - **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides
@@ -487,6 +494,12 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
- **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based), both in target wizard GUI - **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 - **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 - **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 ### V3: certctl Pro
+52
View File
@@ -62,6 +62,8 @@ tags:
description: Certificate discovery — filesystem scanning by agents and network TLS probing description: Certificate discovery — filesystem scanning by agents and network TLS probing
- name: Network Scan - name: Network Scan
description: Network scan target management for active TLS certificate discovery description: Network scan target management for active TLS certificate discovery
- name: Digest
description: Scheduled certificate digest email notifications
paths: paths:
# ─── Health & Auth ─────────────────────────────────────────────────── # ─── Health & Auth ───────────────────────────────────────────────────
@@ -2372,6 +2374,56 @@ paths:
"500": "500":
$ref: "#/components/responses/InternalError" $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: components:
securitySchemes: securitySchemes:
+46
View File
@@ -21,6 +21,7 @@ import (
"github.com/shankar0123/certctl/internal/connector/issuer/local" "github.com/shankar0123/certctl/internal/connector/issuer/local"
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl" opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca" 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" notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie"
notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty" notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty"
notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack" notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack"
@@ -189,6 +190,25 @@ func main() {
logger.Info("OpsGenie notifier enabled") 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 := service.NewNotificationService(notificationRepo, notifierRegistry)
notificationService.SetOwnerRepo(ownerRepo) notificationService.SetOwnerRepo(ownerRepo)
@@ -265,6 +285,26 @@ func main() {
verificationHandler := handler.NewVerificationHandler(verificationService) verificationHandler := handler.NewVerificationHandler(verificationService)
exportService := service.NewExportService(certificateRepo, auditService) exportService := service.NewExportService(certificateRepo, auditService)
exportHandler := handler.NewExportHandler(exportService) 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") logger.Info("initialized all handlers")
// Create context with cancellation // Create context with cancellation
@@ -290,6 +330,11 @@ func main() {
sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval) sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval)
logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String()) 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 // Start scheduler
logger.Info("starting scheduler") logger.Info("starting scheduler")
@@ -319,6 +364,7 @@ func main() {
NetworkScan: networkScanHandler, NetworkScan: networkScanHandler,
Verification: verificationHandler, Verification: verificationHandler,
Export: exportHandler, Export: exportHandler,
Digest: *digestHandler,
}) })
// Register EST (RFC 7030) handlers if enabled // Register EST (RFC 7030) handlers if enabled
if cfg.EST.Enabled { if cfg.EST.Enabled {
+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,15 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "certctl.fullname" . }}-postgres
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: postgres
type: Opaque
stringData:
{{- if not .Values.postgresql.auth.password }}
{{- fail "postgresql.auth.password is required" }}
{{- end }}
password: {{ .Values.postgresql.auth.password | 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 ne .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 }}
{{- with .Values.nodeAffinity }}
affinity:
nodeAffinity:
{{- toYaml . | nindent 10 }}
{{- else if .Values.podAntiAffinity }}
affinity:
podAntiAffinity:
{{- toYaml . | nindent 10 }}
{{- else if .Values.podAffinity }}
affinity:
podAffinity:
{{- toYaml . | nindent 10 }}
{{- end }}
@@ -0,0 +1,19 @@
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 eq .Values.server.auth.type "api-key" }}
{{- if not .Values.server.auth.apiKey }}
{{- fail "server.auth.apiKey is required when server.auth.type is 'api-key'" }}
{{- end }}
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 ### 4. Automatic Renewal
The control plane runs a scheduler with six background loops: The control plane runs a scheduler with seven background loops:
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -461,6 +461,7 @@ flowchart LR
N["Notification Processor\n⏱ every 1m"] N["Notification Processor\n⏱ every 1m"]
SL["Short-Lived Expiry\n⏱ every 30s"] SL["Short-Lived Expiry\n⏱ every 30s"]
NS["Network Scanner\n⏱ every 6h"] NS["Network Scanner\n⏱ every 6h"]
DG["Certificate Digest\n⏱ every 24h"]
end end
R -->|"Find expiring certs\nCreate renewal jobs"| DB[("PostgreSQL")] R -->|"Find expiring certs\nCreate renewal jobs"| DB[("PostgreSQL")]
@@ -469,6 +470,7 @@ flowchart LR
N -->|"Send pending notifications\nEmail / Webhook / Slack"| DB N -->|"Send pending notifications\nEmail / Webhook / Slack"| DB
SL -->|"Expire short-lived certs\nMark as Expired"| DB SL -->|"Expire short-lived certs\nMark as Expired"| DB
NS -->|"Probe TLS endpoints\nStore discovered certs"| DB NS -->|"Probe TLS endpoints\nStore discovered certs"| DB
DG -->|"Generate & send HTML digest\nEmail to recipients"| DB
``` ```
| Loop | Interval | Timeout | Purpose | | Loop | Interval | Timeout | Purpose |
@@ -479,8 +481,9 @@ flowchart LR
| Notification processor | 1 minute | 1 minute | Sends pending notifications via configured channels | | 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) | | 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. | | 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. 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 ### 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. 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`. 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:** **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. 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 ```mermaid
flowchart TB flowchart TB
@@ -861,6 +870,21 @@ flowchart TB
DS --> DEP 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.). 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) ## 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. 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 ### 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." 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. **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: HTTP-01 configuration:
```json ```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). 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: Each notifier is enabled by its configuration env var:
| Notifier | Env Var | Description | | 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 | | 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` | | 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) | | 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 ## API Surface
### Overview ### 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) - REST API with HTTP semantics (GET, POST, PUT, DELETE)
- All endpoints require authentication by default (configurable) - All endpoints require authentication by default (configurable)
- OpenAPI 3.1 spec with full schema documentation - 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 | | **Stats** | 5 | Dashboard summary, certificates by status, expiration timeline, job trends, issuance rate |
| **Metrics** | 2 | JSON metrics (gauges, counters, uptime), Prometheus exposition format | | **Metrics** | 2 | JSON metrics (gauges, counters, uptime), Prometheus exposition format |
| **Verification** | 2 | Submit verification result, get verification status | | **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 | | **EST (RFC 7030)** | 4 | CA certs (PKCS#7), simple enrollment, re-enrollment, CSR attributes |
| **Health** | 4 | Health check, readiness check, auth info, auth check | | **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 ## 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. 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 ## Start Everything
### Docker Compose (Quick Start)
```bash ```bash
git clone https://github.com/shankar0123/certctl.git git clone https://github.com/shankar0123/certctl.git
cd certctl cd certctl
@@ -58,6 +60,22 @@ cp deploy/.env.example deploy/.env
docker compose -f deploy/docker-compose.yml up -d --build 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: Wait about 30 seconds for PostgreSQL to initialize, then verify:
```bash ```bash
@@ -346,6 +364,35 @@ export CERTCTL_API_KEY="test-key-123"
./certctl-cli status # Health + stats ./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) ## MCP Server (AI Integration)
```bash ```bash
+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)
}
}
+5
View File
@@ -64,6 +64,7 @@ type HandlerRegistry struct {
NetworkScan handler.NetworkScanHandler NetworkScan handler.NetworkScanHandler
Verification handler.VerificationHandler Verification handler.VerificationHandler
Export handler.ExportHandler Export handler.ExportHandler
Digest handler.DigestHandler
} }
// RegisterHandlers sets up all API routes with their handlers. // 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 // 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("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment))
r.Register("GET /api/v1/jobs/{id}/verification", http.HandlerFunc(reg.Verification.GetVerificationStatus)) 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/. // RegisterESTHandlers sets up EST (RFC 7030) routes under /.well-known/est/.
+75
View File
@@ -24,6 +24,8 @@ type Config struct {
NetworkScan NetworkScanConfig NetworkScan NetworkScanConfig
EST ESTConfig EST ESTConfig
Verification VerificationConfig Verification VerificationConfig
ACME ACMEConfig
Digest DigestConfig
} }
// NotifierConfig contains configuration for notification connectors. // NotifierConfig contains configuration for notification connectors.
@@ -64,6 +66,34 @@ type NotifierConfig struct {
// OpsGeniePriority sets the default priority for OpsGenie alerts. // OpsGeniePriority sets the default priority for OpsGenie alerts.
// Valid values: "P1", "P2", "P3", "P4", "P5". Default: "P3". // Valid values: "P1", "P2", "P3", "P4", "P5". Default: "P3".
OpsGeniePriority string 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. // KeygenConfig controls where private keys are generated.
@@ -111,6 +141,24 @@ type StepCAConfig struct {
ProvisionerPassword string 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. // ACMEConfig contains ACME issuer connector configuration.
type ACMEConfig struct { type ACMEConfig struct {
// DirectoryURL is the ACME directory URL for certificate issuance. // 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". // Example: "letsencrypt.org" or "zerossl.com". Only used if ChallengeType is "dns-persist-01".
// The record value becomes: "<issuer_domain>; accounturi=<acme_account_uri>" // The record value becomes: "<issuer_domain>; accounturi=<acme_account_uri>"
DNSPersistIssuerDomain string 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. // OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
@@ -349,6 +404,12 @@ func Load() (*Config, error) {
PagerDutySeverity: getEnv("CERTCTL_PAGERDUTY_SEVERITY", "warning"), PagerDutySeverity: getEnv("CERTCTL_PAGERDUTY_SEVERITY", "warning"),
OpsGenieAPIKey: getEnv("CERTCTL_OPSGENIE_API_KEY", ""), OpsGenieAPIKey: getEnv("CERTCTL_OPSGENIE_API_KEY", ""),
OpsGeniePriority: getEnv("CERTCTL_OPSGENIE_PRIORITY", "P3"), 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{ NetworkScan: NetworkScanConfig{
Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false), Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false),
@@ -364,6 +425,20 @@ func Load() (*Config, error) {
Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second), Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second),
Delay: getEnvDuration("CERTCTL_VERIFY_DELAY", 2*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 { 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>". // 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". // Required when ChallengeType is "dns-persist-01". For Let's Encrypt, use "letsencrypt.org".
DNSPersistIssuerDomain string `json:"dns_persist_issuer_domain,omitempty"` 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 // 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. // GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
// Used by the EST /cacerts endpoint. Returns empty string if not available. // Used by the EST /cacerts endpoint. Returns empty string if not available.
GetCACertPEM(ctx context.Context) (string, error) 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. // 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 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") 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 --- // --- Helper Methods ---
// writeTempFile writes data to a temporary file and returns its path. // 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") 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. // Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil) 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 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. // formatEmailMessage formats an email message with standard headers.
func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte { func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte {
message := fmt.Sprintf( message := fmt.Sprintf(
@@ -208,6 +275,19 @@ func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte {
return []byte(message) 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. // formatAlertBody formats an alert notification as email body text.
func (c *Connector) formatAlertBody(alert notifier.Alert) string { func (c *Connector) formatAlertBody(alert notifier.Alert) string {
body := fmt.Sprintf(` body := fmt.Sprintf(`
+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)
}
}
+27
View File
@@ -27,6 +27,7 @@ func RegisterTools(s *gomcp.Server, client *Client) {
registerNotificationTools(s, client) registerNotificationTools(s, client)
registerStatsTools(s, client) registerStatsTools(s, client)
registerMetricsTools(s, client) registerMetricsTools(s, client)
registerDigestTools(s, client)
registerHealthTools(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 ───────────────────────────────────────────────────────── // ── Metrics ─────────────────────────────────────────────────────────
func registerMetricsTools(s *gomcp.Server, c *Client) { func registerMetricsTools(s *gomcp.Server, c *Client) {
+68 -1
View File
@@ -35,6 +35,11 @@ type NetworkScanServicer interface {
ScanAllTargets(ctx context.Context) error 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. // 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, // It runs multiple concurrent loops for renewal checks, job processing, agent health checks,
// and notification processing. // and notification processing.
@@ -44,6 +49,7 @@ type Scheduler struct {
agentService AgentServicer agentService AgentServicer
notificationService NotificationServicer notificationService NotificationServicer
networkScanService NetworkScanServicer networkScanService NetworkScanServicer
digestService DigestServicer
logger *slog.Logger logger *slog.Logger
// Configurable tick intervals // Configurable tick intervals
@@ -53,6 +59,7 @@ type Scheduler struct {
notificationProcessInterval time.Duration notificationProcessInterval time.Duration
shortLivedExpiryCheckInterval time.Duration shortLivedExpiryCheckInterval time.Duration
networkScanInterval time.Duration networkScanInterval time.Duration
digestInterval time.Duration
// Idempotency guards: prevent duplicate execution of slow jobs // Idempotency guards: prevent duplicate execution of slow jobs
renewalCheckRunning atomic.Bool renewalCheckRunning atomic.Bool
@@ -61,6 +68,7 @@ type Scheduler struct {
notificationProcessRunning atomic.Bool notificationProcessRunning atomic.Bool
shortLivedExpiryCheckRunning atomic.Bool shortLivedExpiryCheckRunning atomic.Bool
networkScanRunning atomic.Bool networkScanRunning atomic.Bool
digestRunning atomic.Bool
// Graceful shutdown: wait for in-flight work to complete // Graceful shutdown: wait for in-flight work to complete
wg sync.WaitGroup wg sync.WaitGroup
@@ -90,9 +98,21 @@ func NewScheduler(
notificationProcessInterval: 1 * time.Minute, notificationProcessInterval: 1 * time.Minute,
shortLivedExpiryCheckInterval: 30 * time.Second, shortLivedExpiryCheckInterval: 30 * time.Second,
networkScanInterval: 6 * time.Hour, 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. // SetRenewalCheckInterval configures the interval for renewal checks.
func (s *Scheduler) SetRenewalCheckInterval(d time.Duration) { func (s *Scheduler) SetRenewalCheckInterval(d time.Duration) {
s.renewalCheckInterval = d s.renewalCheckInterval = d
@@ -135,7 +155,10 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
// blocks until they've fully exited (prevents test races). // blocks until they've fully exited (prevents test races).
loopCount := 5 loopCount := 5
if s.networkScanService != nil { if s.networkScanService != nil {
loopCount = 6 loopCount++
}
if s.digestService != nil {
loopCount++
} }
s.wg.Add(loopCount) s.wg.Add(loopCount)
@@ -147,6 +170,9 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
if s.networkScanService != nil { if s.networkScanService != nil {
go func() { defer s.wg.Done(); s.networkScanLoop(ctx) }() 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 // Signal that all loops are launched
close(startedChan) close(startedChan)
@@ -450,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. // 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. // 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. // Call this after the scheduler context has been cancelled to ensure graceful shutdown.
+376
View File
@@ -0,0 +1,376 @@
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{
Status: c.Status,
Count: c.Count,
})
}
}
// 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) { func (a *IssuerConnectorAdapter) GetCACertPEM(ctx context.Context) (string, error) {
return a.connector.GetCACertPEM(ctx) 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
}
+119
View File
@@ -23,6 +23,9 @@ type mockConnectorLayerIssuer struct {
revokeErr error revokeErr error
orderStatusErr error orderStatusErr error
orderStatus *issuer.OrderStatus 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 { 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 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 // Tests for IssueCertificate
func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) { 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") 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) SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
// GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer. // GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer.
GetCACertPEM(ctx context.Context) (string, error) 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. // IssuanceResult holds the result of a certificate issuance or renewal operation.
+11
View File
@@ -716,6 +716,17 @@ func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error)
return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil 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 // Constructor functions for mocks
func newMockCertificateRepository() *mockCertRepo { func newMockCertificateRepository() *mockCertRepo {
+40
View File
@@ -76,6 +76,8 @@ import {
updateNetworkScanTarget, updateNetworkScanTarget,
deleteNetworkScanTarget, deleteNetworkScanTarget,
triggerNetworkScan, triggerNetworkScan,
previewDigest,
sendDigest,
} from './client'; } from './client';
// Mock global fetch // Mock global fetch
@@ -966,4 +968,42 @@ describe('API Client', () => {
expect(result.data[0].verified_at).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 = () => export const getMetrics = () =>
fetchJSON<MetricsResponse>(`${BASE}/metrics`); 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 // Health
export const getHealth = () => fetchJSON<{ status: string }>('/health'); export const getHealth = () => fetchJSON<{ status: string }>('/health');
+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 { useNavigate } from 'react-router-dom';
import { import {
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
@@ -7,7 +8,7 @@ import {
import { import {
getCertificates, getAgents, getJobs, getNotifications, getHealth, getCertificates, getAgents, getJobs, getNotifications, getHealth,
getDashboardSummary, getCertificatesByStatus, getExpirationTimeline, getDashboardSummary, getCertificatesByStatus, getExpirationTimeline,
getJobTrends, getIssuanceRate, getJobTrends, getIssuanceRate, previewDigest, sendDigest,
} from '../api/client'; } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge'; 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() { export default function DashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -293,6 +377,9 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Certificate Digest */}
<DigestCard />
{/* Pending Jobs Banner */} {/* Pending Jobs Banner */}
{pendingJobs > 0 && ( {pendingJobs > 0 && (
<div className="bg-brand-50 border border-brand-200 rounded px-5 py-4 flex items-center justify-between"> <div className="bg-brand-50 border border-brand-200 rounded px-5 py-4 flex items-center justify-between">