mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 16:48:51 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c8d4eca40 | |||
| 836534f2a7 | |||
| 648e2f7ab1 | |||
| 6375909591 | |||
| 3e5ff4b9c3 | |||
| 76d0ce2a0f | |||
| 207f2c6879 | |||
| 46a58d518a | |||
| c5be6d059f | |||
| ec209c9736 | |||
| d4f02c5f4b | |||
| 2409f2e464 | |||
| 225c7141b8 | |||
| 8807a7303d | |||
| a6515b4323 | |||
| 11173a74c6 | |||
| ec0e7a3560 | |||
| a0b9285323 | |||
| 2655493ac8 | |||
| a8fc177118 | |||
| 20378ea7bb | |||
| bcf2c3ae92 | |||
| 5f81de3219 | |||
| 397d2a1588 | |||
| 65567d0d83 | |||
| 0abd984285 | |||
| ec21c9bb29 | |||
| cb2ef9d0e7 | |||
| da79dde611 | |||
| 935ea1bf9f | |||
| 11e752ac01 |
@@ -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
|
||||||
|
|||||||
@@ -7,9 +7,74 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
|
GO_VERSION: '1.22'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
# Cross-compile agent and server binaries for multiple platforms
|
||||||
|
build-binaries:
|
||||||
|
name: Build Cross-Platform Binaries
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# Agent binaries (4 platforms)
|
||||||
|
- os: linux
|
||||||
|
arch: amd64
|
||||||
|
binary: agent
|
||||||
|
- os: linux
|
||||||
|
arch: arm64
|
||||||
|
binary: agent
|
||||||
|
- os: darwin
|
||||||
|
arch: amd64
|
||||||
|
binary: agent
|
||||||
|
- os: darwin
|
||||||
|
arch: arm64
|
||||||
|
binary: agent
|
||||||
|
# Server binaries (2 platforms)
|
||||||
|
- os: linux
|
||||||
|
arch: amd64
|
||||||
|
binary: server
|
||||||
|
- os: linux
|
||||||
|
arch: arm64
|
||||||
|
binary: server
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build ${{ matrix.binary }} binary (${{ matrix.os }}-${{ matrix.arch }})
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.os }}
|
||||||
|
GOARCH: ${{ matrix.arch }}
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
run: |
|
||||||
|
OUTPUT_NAME="certctl-${{ matrix.binary }}-${{ matrix.os }}-${{ matrix.arch }}"
|
||||||
|
go build -ldflags="-w -s -X main.Version=${{ steps.version.outputs.VERSION }}" \
|
||||||
|
-o "dist/${OUTPUT_NAME}" \
|
||||||
|
"./cmd/${{ matrix.binary }}"
|
||||||
|
ls -lh "dist/${OUTPUT_NAME}"
|
||||||
|
|
||||||
|
- name: Upload binaries to release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
dist/certctl-agent-*
|
||||||
|
dist/certctl-server-*
|
||||||
|
|
||||||
|
# Build and push Docker images
|
||||||
|
build-and-push-docker:
|
||||||
name: Build & Push Docker Images
|
name: Build & Push Docker Images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@@ -57,19 +122,67 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Create GitHub Release
|
# Create release notes with all artifacts
|
||||||
|
create-release:
|
||||||
|
name: Create Release Notes
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-binaries, build-and-push-docker]
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create release with notes
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
body: |
|
body: |
|
||||||
## Docker Images
|
## Installation
|
||||||
|
|
||||||
|
### Quick Install (Linux/macOS)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull shankar0123.docker.scarf.sh/certctl-server:${{ steps.version.outputs.VERSION }}
|
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
|
||||||
docker pull shankar0123.docker.scarf.sh/certctl-agent:${{ steps.version.outputs.VERSION }}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
### Manual Binary Download
|
||||||
|
|
||||||
|
Download the appropriate binary for your OS and architecture:
|
||||||
|
|
||||||
|
- **Linux x86_64**: `certctl-agent-linux-amd64`
|
||||||
|
- **Linux ARM64**: `certctl-agent-linux-arm64`
|
||||||
|
- **macOS x86_64**: `certctl-agent-darwin-amd64`
|
||||||
|
- **macOS ARM64 (Apple Silicon)**: `certctl-agent-darwin-arm64`
|
||||||
|
|
||||||
|
Then make it executable and start the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x certctl-agent-linux-amd64
|
||||||
|
sudo mv certctl-agent-linux-amd64 /usr/local/bin/certctl-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Images
|
||||||
|
|
||||||
|
Pull pre-built Docker images for server and agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/shankar0123/certctl-server:${{ steps.version.outputs.VERSION }}
|
||||||
|
docker pull ghcr.io/shankar0123/certctl-agent:${{ steps.version.outputs.VERSION }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the latest tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/shankar0123/certctl-server:latest
|
||||||
|
docker pull ghcr.io/shankar0123/certctl-agent:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/shankar0123/certctl.git
|
git clone https://github.com/shankar0123/certctl.git
|
||||||
@@ -77,3 +190,22 @@ jobs:
|
|||||||
cp deploy/.env.example deploy/.env
|
cp deploy/.env.example deploy/.env
|
||||||
docker compose -f deploy/docker-compose.yml up -d
|
docker compose -f deploy/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Server Binaries
|
||||||
|
|
||||||
|
Pre-compiled server binaries are also available for direct installation:
|
||||||
|
|
||||||
|
- **Linux x86_64**: `certctl-server-linux-amd64`
|
||||||
|
- **Linux ARM64**: `certctl-server-linux-arm64`
|
||||||
|
|
||||||
|
## Helm Chart
|
||||||
|
|
||||||
|
Deploy certctl to Kubernetes using Helm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo add certctl https://github.com/shankar0123/certctl/tree/master/deploy/helm
|
||||||
|
helm repo update
|
||||||
|
helm install certctl certctl/certctl
|
||||||
|
```
|
||||||
|
|
||||||
|
See `deploy/helm/certctl/` for values customization.
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ vendor/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.log
|
*.log
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Private keys (agent-generated, never commit)
|
||||||
|
cmd/agent/*.key
|
||||||
|
cmd/agent/*.pem
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
@@ -57,6 +62,7 @@ certctl-agent
|
|||||||
certctl-cli
|
certctl-cli
|
||||||
/server
|
/server
|
||||||
/agent
|
/agent
|
||||||
|
/cli
|
||||||
|
|
||||||
# Private strategy docs
|
# Private strategy docs
|
||||||
roadmap.md
|
roadmap.md
|
||||||
|
|||||||
@@ -7,6 +7,15 @@
|
|||||||
|
|
||||||
# certctl — Self-Hosted Certificate Lifecycle Platform
|
# certctl — Self-Hosted Certificate Lifecycle Platform
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||||
|
[](https://github.com/shankar0123/certctl/releases)
|
||||||
|
[](https://github.com/shankar0123/certctl/stargazers)
|
||||||
|
|
||||||
|
TLS certificate lifespans are shrinking fast. The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) unanimously in April 2025, setting a phased reduction: **200 days** by March 2026, **100 days** by March 2027, and **47 days** by March 2029. Organizations managing dozens or hundreds of certificates can no longer rely on spreadsheets, calendar reminders, or manual renewal workflows. The math doesn't work — at 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever.
|
||||||
|
|
||||||
|
certctl is a self-hosted platform that automates the entire certificate lifecycle — from issuance through renewal to deployment — with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
timeline
|
timeline
|
||||||
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
|
title TLS Certificate Maximum Lifespan (CA/Browser Forum Ballot SC-081v3)
|
||||||
@@ -18,14 +27,6 @@ timeline
|
|||||||
March 2029 : 47 days
|
March 2029 : 47 days
|
||||||
```
|
```
|
||||||
|
|
||||||
TLS certificate lifespans are shrinking fast. The CA/Browser Forum passed [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) unanimously in April 2025, setting a phased reduction: **200 days** by March 2026, **100 days** by March 2027, and **47 days** by March 2029. Organizations managing dozens or hundreds of certificates can no longer rely on spreadsheets, calendar reminders, or manual renewal workflows. The math doesn't work — at 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever.
|
|
||||||
|
|
||||||
certctl is a self-hosted platform that automates the entire certificate lifecycle — from issuance through renewal to deployment — with zero human intervention. It works with any certificate authority, deploys to any server, and keeps private keys on your infrastructure where they belong.
|
|
||||||
|
|
||||||
[](LICENSE)
|
|
||||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
|
||||||
[](https://github.com/shankar0123/certctl/releases)
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Guide | Description |
|
| Guide | Description |
|
||||||
@@ -38,6 +39,11 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl
|
|||||||
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
|
| [Feature Inventory](docs/features.md) | Complete reference of all V2 capabilities, API endpoints, and configuration |
|
||||||
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
| [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors |
|
||||||
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
| [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides |
|
||||||
|
| [Migrate from Certbot](docs/migrate-from-certbot.md) | Step-by-step migration from Certbot/Let's Encrypt cron jobs |
|
||||||
|
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users with DNS-01 scripts |
|
||||||
|
| [certctl for cert-manager Users](docs/certctl-for-cert-manager-users.md) | Using certctl alongside cert-manager for non-Kubernetes infrastructure |
|
||||||
|
|
||||||
|
> **Next release:** v2.1.0 will be tagged after the full V2 feature suite passes manual QA across all 34 sections of the [testing guide](docs/testing-guide.md). Automated CI (1,300+ Go tests + 211 frontend tests) gates every commit; the manual playbook covers integration, deployment, and UX verification that unit tests can't reach.
|
||||||
|
|
||||||
## Why certctl Exists
|
## Why certctl Exists
|
||||||
|
|
||||||
@@ -53,8 +59,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** — 24 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** — 97 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
|
||||||
@@ -62,7 +68,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).
|
||||||
|
|
||||||
@@ -76,8 +85,10 @@ For the full capability breakdown — revocation infrastructure, policy engine,
|
|||||||
| ACME EAB (ZeroSSL, Google Trust) | Implemented (auto-fetch EAB from ZeroSSL) | `ACME` |
|
| ACME EAB (ZeroSSL, Google Trust) | Implemented (auto-fetch EAB from ZeroSSL) | `ACME` |
|
||||||
| step-ca | Implemented | `StepCA` |
|
| step-ca | Implemented | `StepCA` |
|
||||||
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
||||||
| Vault PKI | Future | — |
|
| Vault PKI | Beta | `VaultPKI` |
|
||||||
| DigiCert | Future | — |
|
| DigiCert CertCentral | Beta | `DigiCert` |
|
||||||
|
|
||||||
|
**Vault PKI and DigiCert connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users.
|
||||||
|
|
||||||
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector.
|
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector.
|
||||||
|
|
||||||
@@ -120,7 +131,7 @@ All connectors are pluggable — build your own by implementing the [connector i
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a href="docs/screenshots/v2-policies.png"><img src="docs/screenshots/v2-policies.png" width="270" alt="Policies"></a><br><b>Policies</b><br><sub>Ownership, lifetime, renewal rules</sub></td>
|
<td><a href="docs/screenshots/v2-policies.png"><img src="docs/screenshots/v2-policies.png" width="270" alt="Policies"></a><br><b>Policies</b><br><sub>Ownership, lifetime, renewal rules</sub></td>
|
||||||
<td><a href="docs/screenshots/v2-profiles.png"><img src="docs/screenshots/v2-profiles.png" width="270" alt="Profiles"></a><br><b>Profiles</b><br><sub>Key types, max TTL, crypto constraints</sub></td>
|
<td><a href="docs/screenshots/v2-profiles.png"><img src="docs/screenshots/v2-profiles.png" width="270" alt="Profiles"></a><br><b>Profiles</b><br><sub>Key types, max TTL, crypto constraints</sub></td>
|
||||||
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca connectors</sub></td>
|
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca, Vault PKI, DigiCert</sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy, Traefik, Caddy deployment</sub></td>
|
<td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy, Traefik, Caddy deployment</sub></td>
|
||||||
@@ -134,7 +145,7 @@ All connectors are pluggable — build your own by implementing the [connector i
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
> **22 operational GUI pages** covering the full certificate lifecycle: dashboard, certificates (list + detail with EKU badges, deployment timeline, TLS verification status), agents, fleet overview, jobs (with approval workflow), notifications, policies, profiles, issuers, targets (wizard with NGINX/Apache/HAProxy/Traefik/Caddy/F5/IIS), owners, teams, agent groups, audit trail, short-lived credentials, discovery triage, and network scan management.
|
> **24 operational GUI pages** covering the full certificate lifecycle: dashboard, certificates (list + detail with EKU badges, deployment timeline, TLS verification status), agents, fleet overview, jobs (list + detail with approval workflow), notifications, policies, profiles, issuers (catalog + detail), targets (list + detail + wizard), owners, teams, agent groups, audit trail, short-lived credentials, discovery triage, network scan management, digest email preview, and observability metrics.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -155,7 +166,7 @@ docker compose -f deploy/docker-compose.yml up -d --build
|
|||||||
|
|
||||||
Wait ~30 seconds, then open **http://localhost:8443** in your browser.
|
Wait ~30 seconds, then open **http://localhost:8443** in your browser.
|
||||||
|
|
||||||
The dashboard comes pre-loaded with 15 demo certificates, 5 agents, policy rules, audit events, and notifications — a realistic snapshot of a certificate inventory so you can explore immediately.
|
The dashboard comes pre-loaded with 32 demo certificates across 7 issuers, 8 agents, 180 days of job history, discovery scan data, and network scan targets — a realistic snapshot of a certificate inventory that looks like it's been running for months.
|
||||||
|
|
||||||
Verify the API:
|
Verify the API:
|
||||||
```bash
|
```bash
|
||||||
@@ -163,13 +174,21 @@ curl http://localhost:8443/health
|
|||||||
# {"status":"healthy"}
|
# {"status":"healthy"}
|
||||||
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
||||||
# 15
|
# 32
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Agent Install (One-Liner)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Detects your OS and architecture, downloads the binary, configures systemd (Linux) or launchd (macOS), and starts the agent. See [install-agent.sh](install-agent.sh) for details.
|
||||||
|
|
||||||
### Manual Build
|
### Manual Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Prerequisites: Go 1.25+, PostgreSQL 16+
|
# Prerequisites: Go 1.25+, PostgreSQL 16+, Docker (for testcontainers-go)
|
||||||
go mod download
|
go mod download
|
||||||
make build
|
make build
|
||||||
|
|
||||||
@@ -191,7 +210,7 @@ export CERTCTL_AGENT_ID=agent-local-01
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Control plane** (Go 1.25 net/http) → **PostgreSQL 16** (21 tables, TEXT primary keys) → **Agents** (key generation, CSR submission, cert deployment). Background scheduler runs 6 loops: renewal checks (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h). See [Architecture Guide](docs/architecture.md) for full system diagrams and data flow.
|
**Control plane** (Go 1.25 net/http) → **PostgreSQL 16** (21 tables, TEXT primary keys) → **Agents** (key generation, CSR submission, cert deployment). Background scheduler runs 7 loops: renewal checks (1h), job processing (30s), agent health (2m), notifications (1m), short-lived cert expiry (30s), network scanning (6h), certificate digest (24h). See [Architecture Guide](docs/architecture.md) for full system diagrams and data flow.
|
||||||
|
|
||||||
### Key Design Decisions
|
### Key Design Decisions
|
||||||
|
|
||||||
@@ -355,7 +374,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).
|
97 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
|
||||||
```
|
```
|
||||||
@@ -390,6 +409,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)
|
||||||
@@ -428,7 +451,7 @@ certctl-cli certs list --format json # JSON output (default: table)
|
|||||||
|
|
||||||
## MCP Server (AI Integration)
|
## MCP Server (AI Integration)
|
||||||
|
|
||||||
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 78 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 80 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install
|
# Install
|
||||||
@@ -464,11 +487,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, 1,500+ 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
|
||||||
@@ -476,8 +499,8 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
|||||||
- **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging
|
- **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging
|
||||||
- **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** — 10 subcommands (list/get/renew/revoke certs, list agents/jobs, import, status, health, metrics), 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
|
||||||
|
|
||||||
@@ -485,17 +508,45 @@ 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: the CA tells certctl the optimal renewal window, gracefully degrading to fixed thresholds when ARI is unavailable
|
||||||
|
- **Scheduled Certificate Digest** — HTML email digests with certificate stats, expiration timeline, job trends, and agent health; configurable daily/hourly/weekly briefings via SMTP
|
||||||
|
- **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, security contexts, resource limits, optional Ingress
|
||||||
|
|
||||||
|
**Also shipped:**
|
||||||
|
- Issuer catalog page (see all supported CAs, configure from dashboard)
|
||||||
|
- Vault PKI and DigiCert CertCentral issuer connectors (Beta)
|
||||||
|
- Turnkey deployment examples (ACME+NGINX, wildcard+DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer)
|
||||||
|
- Migration guides (Certbot, acme.sh, cert-manager complement)
|
||||||
|
- One-line agent install script with cross-compiled binaries
|
||||||
|
|
||||||
|
**Coming in v2.1.0:**
|
||||||
|
- Dynamic issuer and target configuration via GUI (no env var restarts)
|
||||||
|
- First-run onboarding wizard
|
||||||
|
|
||||||
### V3: certctl Pro
|
### V3: certctl Pro
|
||||||
|
|
||||||
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views, and premium CA integrations.
|
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views.
|
||||||
|
|
||||||
### V4+: Cloud, Scale & Passive Discovery
|
### V4+: Cloud, Scale & Passive Discovery
|
||||||
Passive network discovery (TLS listener), Kubernetes integration (cert-manager external issuer, Secrets target), cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support (Vault PKI, Google CAS, EJBCA), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
Passive network discovery (TLS listener), Kubernetes integration (cert-manager external issuer, Secrets target), cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support (Google CAS, EJBCA, Sectigo), and platform-scale features (Terraform provider, multi-tenancy, HSM support).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Turnkey Docker Compose configurations for common scenarios — pick the one closest to your setup and have it running in 2 minutes.
|
||||||
|
|
||||||
|
| Example | Scenario |
|
||||||
|
|---------|----------|
|
||||||
|
| [`examples/acme-nginx/`](examples/acme-nginx/) | Let's Encrypt + NGINX, HTTP-01 challenges |
|
||||||
|
| [`examples/acme-wildcard-dns01/`](examples/acme-wildcard-dns01/) | Wildcard certs via DNS-01 (Cloudflare hook included) |
|
||||||
|
| [`examples/private-ca-traefik/`](examples/private-ca-traefik/) | Local CA (self-signed or sub-CA) + Traefik file provider |
|
||||||
|
| [`examples/step-ca-haproxy/`](examples/step-ca-haproxy/) | Smallstep step-ca + HAProxy combined PEM |
|
||||||
|
| [`examples/multi-issuer/`](examples/multi-issuer/) | ACME for public + Local CA for internal, one dashboard |
|
||||||
|
|
||||||
|
Each directory contains a `docker-compose.yml` and a `README.md` explaining the scenario, prerequisites, and customization.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Certctl is licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not offer certctl as a managed/hosted certificate management service to third parties.
|
Certctl is licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not offer certctl as a managed/hosted certificate management service to third parties. The BSL 1.1 license converts automatically to Apache 2.0 on March 1, 2033, providing perpetual freedom.
|
||||||
|
|
||||||
For licensing inquiries: certctl@proton.me
|
For licensing inquiries: certctl@proton.me
|
||||||
|
|
||||||
|
|||||||
+80
-1
@@ -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 ───────────────────────────────────────────────────
|
||||||
@@ -248,6 +250,8 @@ paths:
|
|||||||
$ref: "#/components/schemas/ManagedCertificate"
|
$ref: "#/components/schemas/ManagedCertificate"
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest"
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
delete:
|
delete:
|
||||||
@@ -259,6 +263,8 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"204":
|
"204":
|
||||||
description: Certificate archived
|
description: Certificate archived
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
@@ -304,6 +310,12 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/StatusResponse"
|
$ref: "#/components/schemas/StatusResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
@@ -818,6 +830,8 @@ paths:
|
|||||||
$ref: "#/components/schemas/Agent"
|
$ref: "#/components/schemas/Agent"
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest"
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
@@ -875,6 +889,8 @@ paths:
|
|||||||
$ref: "#/components/schemas/StatusResponse"
|
$ref: "#/components/schemas/StatusResponse"
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest"
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
@@ -2372,6 +2388,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:
|
||||||
@@ -2417,6 +2483,12 @@ components:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
Conflict:
|
||||||
|
description: Resource conflict
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
InternalError:
|
InternalError:
|
||||||
description: Internal server error
|
description: Internal server error
|
||||||
content:
|
content:
|
||||||
@@ -2519,6 +2591,13 @@ components:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- common_name
|
||||||
|
- renewal_policy_id
|
||||||
|
- issuer_id
|
||||||
|
- owner_id
|
||||||
|
- team_id
|
||||||
|
|
||||||
CertificateVersion:
|
CertificateVersion:
|
||||||
type: object
|
type: object
|
||||||
@@ -2564,7 +2643,7 @@ components:
|
|||||||
# ─── Issuers ─────────────────────────────────────────────────────
|
# ─── Issuers ─────────────────────────────────────────────────────
|
||||||
IssuerType:
|
IssuerType:
|
||||||
type: string
|
type: string
|
||||||
enum: [ACME, GenericCA, StepCA]
|
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert]
|
||||||
|
|
||||||
Issuer:
|
Issuer:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ import (
|
|||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
|
||||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||||
|
digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
||||||
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"
|
||||||
|
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
||||||
|
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"
|
||||||
@@ -132,6 +135,27 @@ func main() {
|
|||||||
}, logger)
|
}, logger)
|
||||||
logger.Info("initialized OpenSSL/Custom CA issuer connector")
|
logger.Info("initialized OpenSSL/Custom CA issuer connector")
|
||||||
|
|
||||||
|
// Initialize Vault PKI issuer connector (for HashiCorp Vault internal PKI).
|
||||||
|
// Uses the Vault HTTP API with token authentication.
|
||||||
|
vaultConnector := vaultissuer.New(&vaultissuer.Config{
|
||||||
|
Addr: os.Getenv("CERTCTL_VAULT_ADDR"),
|
||||||
|
Token: os.Getenv("CERTCTL_VAULT_TOKEN"),
|
||||||
|
Mount: getEnvDefault("CERTCTL_VAULT_MOUNT", "pki"),
|
||||||
|
Role: os.Getenv("CERTCTL_VAULT_ROLE"),
|
||||||
|
TTL: getEnvDefault("CERTCTL_VAULT_TTL", "8760h"),
|
||||||
|
}, logger)
|
||||||
|
logger.Info("initialized Vault PKI issuer connector")
|
||||||
|
|
||||||
|
// Initialize DigiCert CertCentral issuer connector (for enterprise public CA).
|
||||||
|
// Uses the DigiCert REST API with async order model.
|
||||||
|
digicertConnector := digicertissuer.New(&digicertissuer.Config{
|
||||||
|
APIKey: os.Getenv("CERTCTL_DIGICERT_API_KEY"),
|
||||||
|
OrgID: os.Getenv("CERTCTL_DIGICERT_ORG_ID"),
|
||||||
|
ProductType: getEnvDefault("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
|
||||||
|
BaseURL: getEnvDefault("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
|
||||||
|
}, logger)
|
||||||
|
logger.Info("initialized DigiCert CertCentral issuer connector")
|
||||||
|
|
||||||
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
||||||
// "iss-local" matches the seed data issuer ID for the Local CA.
|
// "iss-local" matches the seed data issuer ID for the Local CA.
|
||||||
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
||||||
@@ -144,6 +168,19 @@ func main() {
|
|||||||
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
|
"iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector),
|
||||||
"iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector),
|
"iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conditionally register Vault PKI (only if CERTCTL_VAULT_ADDR is set)
|
||||||
|
if os.Getenv("CERTCTL_VAULT_ADDR") != "" {
|
||||||
|
issuerRegistry["iss-vault"] = service.NewIssuerConnectorAdapter(vaultConnector)
|
||||||
|
logger.Info("Vault PKI issuer registered", "id", "iss-vault")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditionally register DigiCert (only if CERTCTL_DIGICERT_API_KEY is set)
|
||||||
|
if os.Getenv("CERTCTL_DIGICERT_API_KEY") != "" {
|
||||||
|
issuerRegistry["iss-digicert"] = service.NewIssuerConnectorAdapter(digicertConnector)
|
||||||
|
logger.Info("DigiCert CertCentral issuer registered", "id", "iss-digicert")
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
||||||
|
|
||||||
// Initialize revocation repository
|
// Initialize revocation repository
|
||||||
@@ -189,6 +226,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(¬ifyemail.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)
|
||||||
|
|
||||||
@@ -206,6 +262,7 @@ func main() {
|
|||||||
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
||||||
certificateService.SetTargetRepo(targetRepo)
|
certificateService.SetTargetRepo(targetRepo)
|
||||||
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
|
||||||
|
renewalService.SetTargetRepo(targetRepo)
|
||||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
@@ -265,6 +322,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 +367,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 +401,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 {
|
||||||
@@ -497,6 +580,14 @@ func main() {
|
|||||||
logger.Info("certctl server stopped")
|
logger.Info("certctl server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getEnvDefault reads an environment variable with a default fallback.
|
||||||
|
func getEnvDefault(key, defaultVal string) string {
|
||||||
|
if val := os.Getenv(key); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
// getEnvIntDefault parses an integer from a string with a default fallback.
|
// getEnvIntDefault parses an integer from a string with a default fallback.
|
||||||
func getEnvIntDefault(s string, defaultVal int) int {
|
func getEnvIntDefault(s string, defaultVal int) int {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
```
|
||||||
@@ -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).
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create a default fully qualified app name.
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create chart name and version as used by the chart label.
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.chart" -}}
|
||||||
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Common labels
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "certctl.chart" . }}
|
||||||
|
{{ include "certctl.selectorLabels" . }}
|
||||||
|
{{- if .Chart.AppVersion }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
{{- end }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- with .Values.commonLabels }}
|
||||||
|
{{ toYaml . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Selector labels for the main service (server, agent, postgres)
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "certctl.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Server selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.serverSelectorLabels" -}}
|
||||||
|
{{ include "certctl.selectorLabels" . }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Agent selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.agentSelectorLabels" -}}
|
||||||
|
{{ include "certctl.selectorLabels" . }}
|
||||||
|
app.kubernetes.io/component: agent
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
PostgreSQL selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.postgresSelectorLabels" -}}
|
||||||
|
{{ include "certctl.selectorLabels" . }}
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Service account name
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.serviceAccountName" -}}
|
||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
{{- default (include "certctl.fullname" .) .Values.serviceAccount.name }}
|
||||||
|
{{- else }}
|
||||||
|
{{- default "default" .Values.serviceAccount.name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Server image
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.serverImage" -}}
|
||||||
|
{{- $image := .Values.server.image }}
|
||||||
|
{{- printf "%s:%s" $image.repository (coalesce $image.tag .Chart.AppVersion) }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Agent image
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.agentImage" -}}
|
||||||
|
{{- $image := .Values.agent.image }}
|
||||||
|
{{- printf "%s:%s" $image.repository (coalesce $image.tag .Chart.AppVersion) }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
PostgreSQL image
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.postgresImage" -}}
|
||||||
|
{{- $image := .Values.postgresql.image }}
|
||||||
|
{{- printf "%s:%s" $image.repository $image.tag }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Database connection string
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.databaseURL" -}}
|
||||||
|
postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Server URL (for agents)
|
||||||
|
*/}}
|
||||||
|
{{- define "certctl.serverURL" -}}
|
||||||
|
http://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{- if .Values.agent.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-agent
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: agent
|
||||||
|
data:
|
||||||
|
{{- if .Values.agent.discoveryDirs }}
|
||||||
|
discovery-dirs: {{ .Values.agent.discoveryDirs | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
{{- if .Values.agent.enabled }}
|
||||||
|
{{- if eq .Values.agent.kind "DaemonSet" }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: DaemonSet
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-agent
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: agent
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "certctl.agentSelectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.agentSelectorLabels" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.agent.securityContext | nindent 8 }}
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: agent
|
||||||
|
image: {{ include "certctl.agentImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
|
||||||
|
env:
|
||||||
|
- name: CERTCTL_SERVER_URL
|
||||||
|
value: {{ include "certctl.serverURL" . }}
|
||||||
|
- name: CERTCTL_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: api-key
|
||||||
|
- name: CERTCTL_AGENT_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
- name: CERTCTL_KEY_DIR
|
||||||
|
value: {{ .Values.agent.keyDir }}
|
||||||
|
{{- if .Values.agent.discoveryDirs }}
|
||||||
|
- name: CERTCTL_DISCOVERY_DIRS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-agent
|
||||||
|
key: discovery-dirs
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.env }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.agent.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: agent-keys
|
||||||
|
mountPath: {{ .Values.agent.keyDir }}
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
volumes:
|
||||||
|
- name: agent-keys
|
||||||
|
emptyDir:
|
||||||
|
sizeLimit: 1Gi
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
|
{{- else if eq .Values.agent.kind "Deployment" }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-agent
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: agent
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.agent.replicas }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "certctl.agentSelectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.agentSelectorLabels" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.agent.securityContext | nindent 8 }}
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: agent
|
||||||
|
image: {{ include "certctl.agentImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.agent.image.pullPolicy }}
|
||||||
|
env:
|
||||||
|
- name: CERTCTL_SERVER_URL
|
||||||
|
value: {{ include "certctl.serverURL" . }}
|
||||||
|
- name: CERTCTL_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: api-key
|
||||||
|
- name: CERTCTL_AGENT_NAME
|
||||||
|
{{- if .Values.agent.name }}
|
||||||
|
value: {{ .Values.agent.name | quote }}
|
||||||
|
{{- else }}
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
{{- end }}
|
||||||
|
- name: CERTCTL_KEY_DIR
|
||||||
|
value: {{ .Values.agent.keyDir }}
|
||||||
|
{{- if .Values.agent.discoveryDirs }}
|
||||||
|
- name: CERTCTL_DISCOVERY_DIRS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-agent
|
||||||
|
key: discovery-dirs
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.agent.env }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.agent.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: agent-keys
|
||||||
|
mountPath: {{ .Values.agent.keyDir }}
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
volumes:
|
||||||
|
- name: agent-keys
|
||||||
|
emptyDir:
|
||||||
|
sizeLimit: 1Gi
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.ingress.className }}
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- range .Values.ingress.tls }}
|
||||||
|
- hosts:
|
||||||
|
{{- range .hosts }}
|
||||||
|
- {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
|
secretName: {{ .secretName }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.server.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
password: {{ .Values.postgresql.auth.password | default "changeme" | quote }}
|
||||||
|
username: {{ .Values.postgresql.auth.username | quote }}
|
||||||
|
database: {{ .Values.postgresql.auth.database | quote }}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{- if .Values.postgresql.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.postgresql.service.port }}
|
||||||
|
targetPort: postgres
|
||||||
|
protocol: TCP
|
||||||
|
name: postgres
|
||||||
|
selector:
|
||||||
|
{{- include "certctl.postgresSelectorLabels" . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{{- if .Values.postgresql.enabled }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: postgres
|
||||||
|
spec:
|
||||||
|
serviceName: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "certctl.postgresSelectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.postgresSelectorLabels" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.postgresql.securityContext | nindent 8 }}
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: {{ include "certctl.postgresImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- name: postgres
|
||||||
|
containerPort: 5432
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
key: database
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
key: username
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
key: password
|
||||||
|
- name: POSTGRES_INITDB_ARGS
|
||||||
|
value: "--encoding=UTF8"
|
||||||
|
livenessProbe:
|
||||||
|
{{- toYaml .Values.postgresql.livenessProbe | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- toYaml .Values.postgresql.readinessProbe | nindent 12 }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.postgresql.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: postgres-data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
subPath: postgres
|
||||||
|
- name: postgres-init
|
||||||
|
mountPath: /docker-entrypoint-initdb.d
|
||||||
|
volumes:
|
||||||
|
- name: postgres-init
|
||||||
|
emptyDir: {}
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: postgres-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
{{- if .Values.postgresql.storage.storageClass }}
|
||||||
|
storageClassName: {{ .Values.postgresql.storage.storageClass }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.postgresql.storage.size }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
data:
|
||||||
|
log-level: {{ .Values.server.logging.level | quote }}
|
||||||
|
auth-type: {{ .Values.server.auth.type | quote }}
|
||||||
|
keygen-mode: {{ .Values.server.keygen.mode | quote }}
|
||||||
|
rate-limit-rps: {{ .Values.server.rateLimiting.rps | quote }}
|
||||||
|
rate-limit-burst: {{ .Values.server.rateLimiting.burst | quote }}
|
||||||
|
{{- if .Values.server.cors.origins }}
|
||||||
|
cors-origins: {{ .Values.server.cors.origins | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.networkScan.enabled }}
|
||||||
|
network-scan-interval: {{ .Values.server.networkScan.interval | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.est.enabled }}
|
||||||
|
est-issuer-id: {{ .Values.server.est.issuerID | quote }}
|
||||||
|
{{- if .Values.server.est.profileID }}
|
||||||
|
est-profile-id: {{ .Values.server.est.profileID | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.smtp.enabled }}
|
||||||
|
smtp-host: {{ .Values.server.smtp.host | quote }}
|
||||||
|
smtp-port: {{ .Values.server.smtp.port | quote }}
|
||||||
|
smtp-username: {{ .Values.server.smtp.username | quote }}
|
||||||
|
smtp-from-address: {{ .Values.server.smtp.fromAddress | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.issuer.acme.enabled }}
|
||||||
|
acme-directory-url: {{ .Values.server.issuer.acme.directoryURL | quote }}
|
||||||
|
acme-email: {{ .Values.server.issuer.acme.email | quote }}
|
||||||
|
acme-challenge-type: {{ .Values.server.issuer.acme.challengeType | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
spec:
|
||||||
|
{{- if gt (int .Values.server.replicas) 1 }}
|
||||||
|
replicas: {{ .Values.server.replicas }}
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.serverSelectorLabels" . | nindent 8 }}
|
||||||
|
annotations:
|
||||||
|
checksum/config: {{ include (print $.Template.BasePath "/server-configmap.yaml") . | sha256sum }}
|
||||||
|
checksum/secret: {{ include (print $.Template.BasePath "/server-secret.yaml") . | sha256sum }}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.server.securityContext | nindent 8 }}
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: server
|
||||||
|
image: {{ include "certctl.serverImage" . }}
|
||||||
|
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.server.port }}
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: CERTCTL_SERVER_HOST
|
||||||
|
value: "0.0.0.0"
|
||||||
|
- name: CERTCTL_SERVER_PORT
|
||||||
|
value: "{{ .Values.server.port }}"
|
||||||
|
- name: CERTCTL_DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: database-url
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-postgres
|
||||||
|
key: password
|
||||||
|
- name: CERTCTL_LOG_LEVEL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: log-level
|
||||||
|
- name: CERTCTL_LOG_FORMAT
|
||||||
|
value: "json"
|
||||||
|
- name: CERTCTL_AUTH_TYPE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: auth-type
|
||||||
|
{{- if eq .Values.server.auth.type "api-key" }}
|
||||||
|
- name: CERTCTL_AUTH_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: api-key
|
||||||
|
{{- end }}
|
||||||
|
- name: CERTCTL_KEYGEN_MODE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: keygen-mode
|
||||||
|
- name: CERTCTL_RATE_LIMIT_RPS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: rate-limit-rps
|
||||||
|
- name: CERTCTL_RATE_LIMIT_BURST
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: rate-limit-burst
|
||||||
|
{{- if .Values.server.cors.origins }}
|
||||||
|
- name: CERTCTL_CORS_ORIGINS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: cors-origins
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.networkScan.enabled }}
|
||||||
|
- name: CERTCTL_NETWORK_SCAN_ENABLED
|
||||||
|
value: "true"
|
||||||
|
- name: CERTCTL_NETWORK_SCAN_INTERVAL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: network-scan-interval
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.est.enabled }}
|
||||||
|
- name: CERTCTL_EST_ENABLED
|
||||||
|
value: "true"
|
||||||
|
- name: CERTCTL_EST_ISSUER_ID
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: est-issuer-id
|
||||||
|
{{- if .Values.server.est.profileID }}
|
||||||
|
- name: CERTCTL_EST_PROFILE_ID
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: est-profile-id
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.smtp.enabled }}
|
||||||
|
- name: CERTCTL_SMTP_HOST
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: smtp-host
|
||||||
|
- name: CERTCTL_SMTP_PORT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: smtp-port
|
||||||
|
- name: CERTCTL_SMTP_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: smtp-username
|
||||||
|
- name: CERTCTL_SMTP_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: smtp-password
|
||||||
|
- name: CERTCTL_SMTP_FROM_ADDRESS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: smtp-from-address
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.issuer.acme.enabled }}
|
||||||
|
- name: CERTCTL_ACME_DIRECTORY_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: acme-directory-url
|
||||||
|
- name: CERTCTL_ACME_EMAIL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: acme-email
|
||||||
|
- name: CERTCTL_ACME_CHALLENGE_TYPE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: acme-challenge-type
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.server.env }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
livenessProbe:
|
||||||
|
{{- toYaml .Values.server.livenessProbe | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- toYaml .Values.server.readinessProbe | nindent 12 }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.server.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
{{- if .Values.server.volumeMounts }}
|
||||||
|
{{- toYaml .Values.server.volumeMounts | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
volumes:
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
|
{{- if .Values.server.volumes }}
|
||||||
|
{{- toYaml .Values.server.volumes | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.nodeAffinity }}
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
{{- toYaml .Values.nodeAffinity | nindent 10 }}
|
||||||
|
{{- else if .Values.podAntiAffinity }}
|
||||||
|
affinity:
|
||||||
|
podAntiAffinity:
|
||||||
|
{{- toYaml .Values.podAntiAffinity | nindent 10 }}
|
||||||
|
{{- else if .Values.podAffinity }}
|
||||||
|
affinity:
|
||||||
|
podAffinity:
|
||||||
|
{{- toYaml .Values.podAffinity | nindent 10 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
database-url: postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ include "certctl.fullname" . }}-postgres:5432/{{ .Values.postgresql.auth.database }}?sslmode=disable
|
||||||
|
{{- if and (eq .Values.server.auth.type "api-key") .Values.server.auth.apiKey }}
|
||||||
|
api-key: {{ .Values.server.auth.apiKey | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.server.smtp.enabled }}
|
||||||
|
smtp-password: {{ .Values.server.smtp.password | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
app.kubernetes.io/component: server
|
||||||
|
{{- with .Values.server.service.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.server.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.server.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
{{- include "certctl.serverSelectorLabels" . | nindent 4 }}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.serviceAccountName" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.serviceAccount.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.rbac.create }}
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
rules: []
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: {{ include "certctl.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "certctl.labels" . | nindent 4 }}
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: {{ include "certctl.fullname" . }}
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: {{ include "certctl.serviceAccountName" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
{{- end }}
|
||||||
@@ -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}"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
+40
-12
@@ -45,7 +45,7 @@ New to certificates? Read the [Concepts Guide](concepts.md) first.
|
|||||||
### Design Principles
|
### Design Principles
|
||||||
|
|
||||||
1. **Private Key Isolation** — Agents generate ECDSA P-256 keys locally and submit CSRs only. Private keys never touch the control plane. Server-side keygen available via `CERTCTL_KEYGEN_MODE=server` for demo only.
|
1. **Private Key Isolation** — Agents generate ECDSA P-256 keys locally and submit CSRs only. Private keys never touch the control plane. Server-side keygen available via `CERTCTL_KEYGEN_MODE=server` for demo only.
|
||||||
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work. For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
|
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work and receive only jobs assigned to their targets (routed via `agent_id` on jobs or through target→agent relationships). For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
|
||||||
3. **Sub-CA Capable** — The Local CA can operate as a subordinate CA under an enterprise root (e.g., ADCS). Load a pre-signed CA cert+key from disk and all issued certs chain to the enterprise trust hierarchy. Self-signed mode remains the default for development/demos.
|
3. **Sub-CA Capable** — The Local CA can operate as a subordinate CA under an enterprise root (e.g., ADCS). Load a pre-signed CA cert+key from disk and all issued certs chain to the enterprise trust hierarchy. Self-signed mode remains the default for development/demos.
|
||||||
4. **GUI as Primary Interface** — The web dashboard is the operational control plane, not a secondary viewer. Every backend feature ships with its corresponding GUI surface.
|
4. **GUI as Primary Interface** — The web dashboard is the operational control plane, not a secondary viewer. Every backend feature ships with its corresponding GUI surface.
|
||||||
5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
|
5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
|
||||||
@@ -61,7 +61,7 @@ flowchart TB
|
|||||||
API["REST API\n(Go net/http, :8443)"]
|
API["REST API\n(Go net/http, :8443)"]
|
||||||
SVC["Service Layer"]
|
SVC["Service Layer"]
|
||||||
REPO["Repository Layer\n(database/sql + lib/pq)"]
|
REPO["Repository Layer\n(database/sql + lib/pq)"]
|
||||||
SCHED["Background Scheduler\n6 loops"]
|
SCHED["Background Scheduler\n7 loops"]
|
||||||
DASH["Web Dashboard\n(React SPA)"]
|
DASH["Web Dashboard\n(React SPA)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -80,13 +80,16 @@ flowchart TB
|
|||||||
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"]
|
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"]
|
||||||
CA3["step-ca\n(/sign API)"]
|
CA3["step-ca\n(/sign API)"]
|
||||||
CA4["OpenSSL / Custom CA\n(script-based)"]
|
CA4["OpenSSL / Custom CA\n(script-based)"]
|
||||||
CA6["Vault PKI\n(planned)"]
|
CA6["Vault PKI\n(token auth, /sign API)"]
|
||||||
|
CA7["DigiCert CertCentral\n(async order model)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Target Systems"
|
subgraph "Target Systems"
|
||||||
T1["NGINX\n(file write + reload)"]
|
T1["NGINX\n(file write + reload)"]
|
||||||
T4["Apache httpd\n(file write + reload)"]
|
T4["Apache httpd\n(file write + reload)"]
|
||||||
T5["HAProxy\n(combined PEM + reload)"]
|
T5["HAProxy\n(combined PEM + reload)"]
|
||||||
|
T6["Traefik\n(file provider)"]
|
||||||
|
T7["Caddy\n(admin API / file)"]
|
||||||
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
|
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
|
||||||
T3["IIS\n(agent-local PowerShell, planned)"]
|
T3["IIS\n(agent-local PowerShell, planned)"]
|
||||||
end
|
end
|
||||||
@@ -96,7 +99,7 @@ flowchart TB
|
|||||||
SVC --> REPO
|
SVC --> REPO
|
||||||
REPO --> PG
|
REPO --> PG
|
||||||
SCHED --> SVC
|
SCHED --> SVC
|
||||||
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3
|
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3 & CA4 & CA6 & CA7
|
||||||
|
|
||||||
A1 & A2 & A3 -->|"CSR + Heartbeat"| API
|
A1 & A2 & A3 -->|"CSR + Heartbeat"| API
|
||||||
API -->|"Cert + Chain\n(NO private key)"| A1 & A2 & A3
|
API -->|"Cert + Chain\n(NO private key)"| A1 & A2 & A3
|
||||||
@@ -450,7 +453,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 +464,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 +473,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 +484,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.
|
||||||
|
|
||||||
@@ -503,7 +509,8 @@ flowchart TB
|
|||||||
II --> ACME["ACME v2"]
|
II --> ACME["ACME v2"]
|
||||||
II --> SC["step-ca"]
|
II --> SC["step-ca"]
|
||||||
II --> OC["OpenSSL / Custom CA"]
|
II --> OC["OpenSSL / Custom CA"]
|
||||||
II --> VP["Vault PKI (planned)"]
|
II --> VP["Vault PKI"]
|
||||||
|
II --> DC["DigiCert CertCentral"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Target Connectors"
|
subgraph "Target Connectors"
|
||||||
@@ -567,7 +574,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), **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts), **Vault PKI** (HashiCorp Vault's PKI secrets engine via /sign API with token auth), and **DigiCert** (commercial CA via CertCentral REST API with async order processing). 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
|
||||||
|
|
||||||
@@ -640,7 +651,7 @@ type ESTService interface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, and OpenSSL connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
|
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, OpenSSL, Vault, and DigiCert connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
|
||||||
|
|
||||||
**Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID.
|
**Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID.
|
||||||
|
|
||||||
@@ -744,7 +755,7 @@ The HTTP middleware stack processes requests in the following order (see `cmd/se
|
|||||||
|
|
||||||
### Concurrency Safety
|
### Concurrency Safety
|
||||||
|
|
||||||
The background scheduler uses `sync/atomic.Bool` idempotency guards on all 6 loops — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit.
|
The background scheduler uses `sync/atomic.Bool` idempotency guards on all 7 loops — if a tick fires while the previous iteration is still running, it skips. A `sync.WaitGroup` tracks all in-flight goroutines. `WaitForCompletion(timeout)` blocks during shutdown until all work finishes or the timeout expires, preventing state corruption from mid-flight database operations during process exit.
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
@@ -763,7 +774,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 +846,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 +874,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)
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# certctl for cert-manager Users
|
||||||
|
|
||||||
|
You run cert-manager inside Kubernetes and it works well for in-cluster certificates. But you also have VMs, bare-metal servers, network appliances, and legacy systems outside the cluster. cert-manager can't reach those. This guide shows how certctl complements cert-manager to give you unified certificate visibility and automation across your entire infrastructure.
|
||||||
|
|
||||||
|
## Not a Replacement
|
||||||
|
|
||||||
|
cert-manager is the right tool for in-cluster certs. It's tightly integrated with Kubernetes:
|
||||||
|
- Native CRDs (Certificate, ClusterIssuer, Issuer)
|
||||||
|
- Automatic cert injection into Ingress and Service objects
|
||||||
|
- Controller-driven renewal within the cluster
|
||||||
|
|
||||||
|
**certctl does not replace this.** Instead, it extends your certificate management to everything outside Kubernetes: VMs, bare metal, network appliances, Windows servers, and legacy systems.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
Your setup:
|
||||||
|
- **cert-manager**: handles all certs in Kubernetes (TLS for Ingress, service-to-service, internal services)
|
||||||
|
- **Everything else**: NGINX/Apache on VMs, HAProxy load balancers on bare metal, network appliances, Windows servers with IIS — these are managed inconsistently. Maybe Certbot cron jobs, maybe manual renewal, maybe deprecated cert files sitting around.
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- No unified visibility — you don't know when non-Kubernetes certs expire
|
||||||
|
- Renewal failures go unnoticed until the cert is already expired
|
||||||
|
- Audit trail fragmented across multiple tools
|
||||||
|
- Scaling to hundreds of machines becomes impossible
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or self-hosted). Deploy agents on your VMs, bare metal, and network appliances. One dashboard shows:
|
||||||
|
- **All cert-manager certs** via discovery scanning (agents find cert-manager-issued certs copied to target machines, or scan the cluster directly)
|
||||||
|
- **All certctl-managed certs** issued by shared issuers (ACME, step-ca, Vault PKI (planned), private CA)
|
||||||
|
- **Unified renewal and deployment** across both worlds
|
||||||
|
- **Single pane of glass** with expiration timeline, renewal status, deployment verification, audit trail
|
||||||
|
|
||||||
|
## How to Set Up
|
||||||
|
|
||||||
|
### 1. Install certctl Control Plane
|
||||||
|
|
||||||
|
**Option A: Docker Compose** (quickest for evaluation)
|
||||||
|
```bash
|
||||||
|
cd /opt/certctl
|
||||||
|
docker compose up -d
|
||||||
|
# Dashboard & API: http://localhost:8443
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Kubernetes** (recommended for prod)
|
||||||
|
```bash
|
||||||
|
helm install certctl deploy/helm/certctl/ \
|
||||||
|
--set auth.apiKey=YOUR_SECURE_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Deploy Agents to Non-Kubernetes Infrastructure
|
||||||
|
|
||||||
|
On each VM, bare-metal server, or appliance (via proxy agent):
|
||||||
|
```bash
|
||||||
|
# Linux amd64
|
||||||
|
curl -sSL https://github.com/shankar0123/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64 \
|
||||||
|
-o /usr/local/bin/certctl-agent
|
||||||
|
chmod +x /usr/local/bin/certctl-agent
|
||||||
|
|
||||||
|
# Config
|
||||||
|
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
||||||
|
CERTCTL_SERVER_URL=http://certctl-control-plane:8443
|
||||||
|
CERTCTL_API_KEY=your-api-key
|
||||||
|
CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live
|
||||||
|
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
||||||
|
EOF
|
||||||
|
sudo chmod 600 /etc/certctl/agent.env
|
||||||
|
|
||||||
|
# Start
|
||||||
|
sudo systemctl start certctl-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Enable Discovery Scanning
|
||||||
|
|
||||||
|
Agents scan configured directories and report back all existing certs. In the dashboard:
|
||||||
|
- **Discovery** page: all found certs grouped by agent
|
||||||
|
- Claim cert-manager certs to link them with Kubernetes metadata
|
||||||
|
- Dismiss obsolete certs
|
||||||
|
|
||||||
|
### 4. Configure Shared Issuers
|
||||||
|
|
||||||
|
Set up the same issuer certctl uses for non-Kubernetes certs:
|
||||||
|
- **ACME** (Let's Encrypt, for public certs)
|
||||||
|
- **step-ca** (Smallstep, for internal certs)
|
||||||
|
- **Vault PKI** (planned) (HashiCorp Vault, for enterprise PKI)
|
||||||
|
- **Private CA** (your own internal root CA)
|
||||||
|
|
||||||
|
No new CA infrastructure needed. If cert-manager already uses your CA, certctl points to the same one.
|
||||||
|
|
||||||
|
### 5. Create Policies for Non-Kubernetes Certs
|
||||||
|
|
||||||
|
Go to **Policies** → **+ New Policy** to create enforcement rules:
|
||||||
|
- **Name:** e.g., "VM Certificate Policy"
|
||||||
|
- **Type:** `expiration_window` or `key_algorithm` (enforce renewal thresholds or crypto requirements)
|
||||||
|
- **Severity:** `high`
|
||||||
|
- **Config:** set your enforcement parameters
|
||||||
|
|
||||||
|
Certificates are linked to issuers and profiles when created or claimed from discovery. Policies add guardrails — enforcing key algorithm requirements, expiration windows, and other compliance rules across your fleet.
|
||||||
|
|
||||||
|
### 6. View Unified Inventory
|
||||||
|
|
||||||
|
**Dashboard** shows:
|
||||||
|
- Certificate status heatmap (all 1000 certs: cert-manager + certctl)
|
||||||
|
- Renewal job trends (both types)
|
||||||
|
- Expiration timeline (30/60/90 days)
|
||||||
|
- Agent fleet status (all infrastructure)
|
||||||
|
|
||||||
|
**Certificates** page filters by issuer (show me all ACME certs, or all step-ca certs):
|
||||||
|
- cert-manager certs discovered from Kubernetes nodes
|
||||||
|
- certctl-managed certs on VMs
|
||||||
|
- Network appliance certs auto-discovered
|
||||||
|
|
||||||
|
## Shared Infrastructure
|
||||||
|
|
||||||
|
If cert-manager and certctl both use the same CA:
|
||||||
|
- **ACME**: cert-manager uses ClusterIssuer + certctl uses ACME connector → same Let's Encrypt account, transparent coexistence
|
||||||
|
- **step-ca**: cert-manager uses external issuer CRD + certctl uses step-ca connector → same provisioner, shared certificate inventory
|
||||||
|
- **Vault PKI** (planned): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
|
||||||
|
|
||||||
|
No conflict. They just issue certs through the same CA. certctl's discovery scanning finds cert-manager-issued certs and shows them alongside certctl-managed ones.
|
||||||
|
|
||||||
|
## Key Differences from cert-manager
|
||||||
|
|
||||||
|
| Feature | cert-manager | certctl |
|
||||||
|
|---------|--------------|---------|
|
||||||
|
| Target | In-cluster (Kubernetes) | Out-of-cluster (VMs, bare metal, appliances) |
|
||||||
|
| Configuration | CRDs (Certificate, ClusterIssuer, Issuer) | API + Dashboard (JSON REST) |
|
||||||
|
| Deployment | Injected into Secret objects, mounted by pods | Agent pulls work, deploys via target-specific API (file, service restart, proxy agent) |
|
||||||
|
| Renewal | Controller watches Certificate CRDs, triggers renewal when needed | Scheduler checks thresholds, agents poll for work |
|
||||||
|
| Audit | Kubernetes event log | Immutable append-only audit trail |
|
||||||
|
| Visibility | Per-namespace, per-resource | Fleet-wide, unified inventory |
|
||||||
|
|
||||||
|
## Future Integration
|
||||||
|
|
||||||
|
On the roadmap (V4): **cert-manager external issuer** — certctl acts as a ClusterIssuer backend for Kubernetes. This would allow cert-manager to request certificates from certctl, which could issue them via any of its connectors (step-ca, Vault, private CA, etc.). Pure integration play; no breaking changes.
|
||||||
|
|
||||||
|
For now: cert-manager handles Kubernetes, certctl handles everything else. They coexist seamlessly.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Review [Quick Start](./quickstart.md) for a 5-minute demo
|
||||||
|
2. Explore [Architecture](./architecture.md#agents) for deployment architecture
|
||||||
|
3. Read about [Discovery Scanning](./quickstart.md#certificate-discovery) to auto-find certs
|
||||||
|
4. Check [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
||||||
@@ -183,13 +183,14 @@ Each section includes:
|
|||||||
|
|
||||||
- **Health Endpoint** — `GET /health` returns 200 OK with service status. Consumed by Docker health checks and Kubernetes probes.
|
- **Health Endpoint** — `GET /health` returns 200 OK with service status. Consumed by Docker health checks and Kubernetes probes.
|
||||||
- **Readiness Endpoint** — `GET /ready` returns 200 OK when the database is connected and migrations are applied.
|
- **Readiness Endpoint** — `GET /ready` returns 200 OK when the database is connected and migrations are applied.
|
||||||
- **Background Scheduler Monitoring** — 6 background loops run on a fixed schedule:
|
- **Background Scheduler Monitoring** — 7 background loops run on a fixed schedule:
|
||||||
- Renewal loop: every 1 hour, scans for certificates approaching renewal threshold
|
- Renewal loop: every 1 hour, scans for certificates approaching renewal threshold
|
||||||
- Job processor loop: every 30 seconds, picks up pending/waiting jobs and advances their state
|
- Job processor loop: every 30 seconds, picks up pending/waiting jobs and advances their state
|
||||||
- Health check loop: every 2 minutes, pings agents to detect downtime
|
- Health check loop: every 2 minutes, pings agents to detect downtime
|
||||||
- Notification dispatcher loop: every 1 minute, sends queued alerts
|
- Notification dispatcher loop: every 1 minute, sends queued alerts
|
||||||
- Short-lived cert expiry loop: every 30 seconds, marks expired short-lived credentials
|
- Short-lived cert expiry loop: every 30 seconds, marks expired short-lived credentials
|
||||||
- Network scanner loop: every 6 hours, scans enabled TLS endpoints for certificate discovery
|
- Network scanner loop: every 6 hours, scans enabled TLS endpoints for certificate discovery
|
||||||
|
- Digest emailer loop: every 24 hours, sends scheduled certificate digest email to configured recipients
|
||||||
Each loop includes error handling and logs failures via structured slog.
|
Each loop includes error handling and logs failures via structured slog.
|
||||||
- **Metrics Endpoints** — Two formats for monitoring integration:
|
- **Metrics Endpoints** — Two formats for monitoring integration:
|
||||||
- `GET /api/v1/metrics` — JSON object with gauges, counters, and uptime for custom dashboards
|
- `GET /api/v1/metrics` — JSON object with gauges, counters, and uptime for custom dashboards
|
||||||
@@ -452,7 +453,7 @@ Each section includes:
|
|||||||
| | Metrics JSON Endpoint | `GET /api/v1/metrics` (gauges, counters, uptime) | ✅ | ✅ | Set thresholds, configure alerting |
|
| | Metrics JSON Endpoint | `GET /api/v1/metrics` (gauges, counters, uptime) | ✅ | ✅ | Set thresholds, configure alerting |
|
||||||
| | Stats API (time-series) | `GET /api/v1/stats/*` (summary, status, expiration, jobs, issuance) | ✅ | ✅ | Integrate into dashboards, SLO tracking |
|
| | Stats API (time-series) | `GET /api/v1/stats/*` (summary, status, expiration, jobs, issuance) | ✅ | ✅ | Integrate into dashboards, SLO tracking |
|
||||||
| | Structured Logging | `slog` middleware with request IDs | ✅ | ✅ | Aggregate logs to SIEM, define retention policy |
|
| | Structured Logging | `slog` middleware with request IDs | ✅ | ✅ | Aggregate logs to SIEM, define retention policy |
|
||||||
| | Background Scheduler | 6 loops (renewal 1h, jobs 30s, health 2m, notifications 1m, short-lived 30s, network scan 6h) | ✅ | ✅ | Alert on scheduler loop failures |
|
| | Background Scheduler | 7 loops (renewal 1h, jobs 30s, health 2m, notifications 1m, short-lived 30s, network scan 6h, digest 24h) | ✅ | ✅ | Alert on scheduler loop failures |
|
||||||
| **CC7.2** Anomaly Detection | Immutable API Audit Trail | `internal/api/middleware/audit.go`, `GET /api/v1/audit` | ✅ | Enhanced (SIEM export) | Integrate into SIEM, search for anomalies, archive long-term |
|
| **CC7.2** Anomaly Detection | Immutable API Audit Trail | `internal/api/middleware/audit.go`, `GET /api/v1/audit` | ✅ | Enhanced (SIEM export) | Integrate into SIEM, search for anomalies, archive long-term |
|
||||||
| | Expiration Threshold Alerting | Configurable per-policy (default 30/14/7/0 days) | ✅ | ✅ | Configure thresholds, integrate notifications |
|
| | Expiration Threshold Alerting | Configurable per-policy (default 30/14/7/0 days) | ✅ | ✅ | Configure thresholds, integrate notifications |
|
||||||
| | Status Auto-Transitions | Active → Expiring (30d) → Expired (0d) | ✅ | ✅ | Monitor status changes in audit trail |
|
| | Status Auto-Transitions | Active → Expiring (30d) → Expired (0d) | ✅ | ✅ | Monitor status changes in audit trail |
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
+108
-5
@@ -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
|
||||||
{
|
{
|
||||||
@@ -310,12 +312,55 @@ The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used b
|
|||||||
|
|
||||||
Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
|
Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
|
||||||
|
|
||||||
### Planned Issuers
|
### Built-in: Vault PKI
|
||||||
|
|
||||||
The following issuer connectors are planned for future milestones:
|
The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving.
|
||||||
|
|
||||||
- **Vault PKI** — HashiCorp Vault's PKI secrets engine for organizations using Vault as their internal CA (planned for V4.0+).
|
**Configuration:**
|
||||||
- **DigiCert** — Commercial CA integration via DigiCert's REST API (planned).
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_VAULT_ADDR` | — | Vault server address (e.g., `https://vault.internal:8200`) |
|
||||||
|
| `CERTCTL_VAULT_TOKEN` | — | Vault auth token with permissions on the PKI mount |
|
||||||
|
| `CERTCTL_VAULT_MOUNT` | `pki` | PKI secrets engine mount path |
|
||||||
|
| `CERTCTL_VAULT_ROLE` | — | PKI role name for certificate signing |
|
||||||
|
| `CERTCTL_VAULT_TTL` | `8760h` | Certificate validity period (TTL) |
|
||||||
|
|
||||||
|
The connector is registered in the issuer registry under `iss-vault`. Vault issues certificates synchronously via the `/v1/{mount}/sign/{role}` API with `X-Vault-Token` header authentication. The issued certificate is parsed to extract serial number, validity dates, and chain information.
|
||||||
|
|
||||||
|
**Note:** CRL and OCSP are managed by Vault itself. Clients should validate certificate status against Vault's own CRL/OCSP endpoints (`GET /v1/{mount}/crl` and Vault's OCSP responder). certctl does not generate local CRL/OCSP for Vault-issued certificates. Revocation is recorded locally but Vault is the authoritative source.
|
||||||
|
|
||||||
|
Location: `internal/connector/issuer/vault/vault.go`
|
||||||
|
|
||||||
|
### Built-in: DigiCert CertCentral
|
||||||
|
|
||||||
|
The DigiCert connector integrates with DigiCert's CertCentral REST API for ordering and managing certificates from DigiCert's commercial CA. It supports both Domain Validated (DV) and Organization/Extended Validated (OV/EV) certificates, with async order processing.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTCTL_DIGICERT_API_KEY` | — | DigiCert API key (X-DC-DEVKEY header) |
|
||||||
|
| `CERTCTL_DIGICERT_ORG_ID` | — | DigiCert organization ID |
|
||||||
|
| `CERTCTL_DIGICERT_PRODUCT_TYPE` | `ssl_basic` | Certificate product (e.g., `ssl_basic`, `ssl_plus`, `ssl_ev`) |
|
||||||
|
| `CERTCTL_DIGICERT_BASE_URL` | `https://www.digicert.com/services/v2` | DigiCert API base URL |
|
||||||
|
|
||||||
|
The connector submits certificate orders to DigiCert's `/order/certificate/create` API. DV certificates may issue immediately; OV/EV certificates require validation (handled by DigiCert) and poll-based completion. The connector periodically checks order status via `/order/certificate/{order_id}` until the certificate is available.
|
||||||
|
|
||||||
|
**Authentication:** API key passed via `X-DC-DEVKEY` header, with organization ID in request body.
|
||||||
|
|
||||||
|
**Note:** CRL and OCSP are managed by DigiCert. Clients should validate certificate status against DigiCert's infrastructure. certctl records the revocation locally but does not notify DigiCert for revocation — use DigiCert's dashboard for revocation management.
|
||||||
|
|
||||||
|
Location: `internal/connector/issuer/digicert/digicert.go`
|
||||||
|
|
||||||
|
### Coming in V2.2+
|
||||||
|
|
||||||
|
The following issuer connectors are planned for future releases:
|
||||||
|
|
||||||
|
- **Entrust** — Enterprise CA via Entrust API
|
||||||
|
- **Sectigo** — Commercial CA integration via Sectigo REST API
|
||||||
|
- **Google CAS** — Google Cloud Certificate Authority Service
|
||||||
|
- **AWS ACM Private CA** — AWS-managed private CA
|
||||||
|
|
||||||
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
||||||
|
|
||||||
@@ -622,11 +667,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) |
|
||||||
|
|||||||
@@ -1153,7 +1153,7 @@ flowchart TB
|
|||||||
API["REST API\nGo net/http"]
|
API["REST API\nGo net/http"]
|
||||||
SVC["Service Layer\nBusiness Logic"]
|
SVC["Service Layer\nBusiness Logic"]
|
||||||
REPO["Repository Layer\ndatabase/sql + lib/pq"]
|
REPO["Repository Layer\ndatabase/sql + lib/pq"]
|
||||||
SCHED["Scheduler\n6 background loops"]
|
SCHED["Scheduler\n7 background loops"]
|
||||||
CONN["Connector Registry\nIssuer + Target + Notifier"]
|
CONN["Connector Registry\nIssuer + Target + Notifier"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+149
-5
@@ -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.
|
||||||
@@ -908,7 +1051,7 @@ curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/jobs/j-abc123/approve -d '{"reas
|
|||||||
3. **Approve** → `POST /api/v1/jobs/{id}/approve` → Job → `Running`
|
3. **Approve** → `POST /api/v1/jobs/{id}/approve` → Job → `Running`
|
||||||
4. **Reject** → `POST /api/v1/jobs/{id}/reject` + reason → Job → `Cancelled`
|
4. **Reject** → `POST /api/v1/jobs/{id}/reject` + reason → Job → `Cancelled`
|
||||||
|
|
||||||
### Background Scheduler (6 loops)
|
### Background Scheduler (7 loops)
|
||||||
| Loop | Interval | Task |
|
| Loop | Interval | Task |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| **Renewal Checker** | 1 hour | Scan policies; trigger renewals if cert expires soon |
|
| **Renewal Checker** | 1 hour | Scan policies; trigger renewals if cert expires soon |
|
||||||
@@ -917,6 +1060,7 @@ curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/jobs/j-abc123/approve -d '{"reas
|
|||||||
| **Notification Processor** | 1 minute | Send queued notifications (email, Slack, webhook, etc.) |
|
| **Notification Processor** | 1 minute | Send queued notifications (email, Slack, webhook, etc.) |
|
||||||
| **Short-Lived Cleanup** | 30 seconds | Audit short-lived credential expirations |
|
| **Short-Lived Cleanup** | 30 seconds | Audit short-lived credential expirations |
|
||||||
| **Network Scanner** | 6 hours | Scan enabled network targets; discover TLS certificates |
|
| **Network Scanner** | 6 hours | Scan enabled network targets; discover TLS certificates |
|
||||||
|
| **Digest Emailer** | 24 hours | Send HTML certificate digest email to configured recipients |
|
||||||
|
|
||||||
All loops have configurable intervals via environment variables (`CERTCTL_SCHEDULER_*_INTERVAL`).
|
All loops have configurable intervals via environment variables (`CERTCTL_SCHEDULER_*_INTERVAL`).
|
||||||
|
|
||||||
@@ -1124,7 +1268,7 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
|||||||
### Docker Compose Deployment
|
### Docker Compose Deployment
|
||||||
- **Services** — PostgreSQL 16, certctl server, agent
|
- **Services** — PostgreSQL 16, certctl server, agent
|
||||||
- **Health Checks** — On all services (server health check, database readiness)
|
- **Health Checks** — On all services (server health check, database readiness)
|
||||||
- **Seed Data** — Demo dataset with 15 certs, 5 agents, 5 targets, policies, audit events
|
- **Seed Data** — Demo dataset with 35 certs across 5 issuers, 8 agents, 8 targets, 90 days of job history, discovery data, network scans, policies, audit events
|
||||||
- **Credentials** — Environment variables in `.env` file; app.key for API key
|
- **Credentials** — Environment variables in `.env` file; app.key for API key
|
||||||
|
|
||||||
### PostgreSQL Schema
|
### PostgreSQL Schema
|
||||||
@@ -1325,8 +1469,8 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
|||||||
| **Bulk revocation** | ✗ | ✓ | Planned V3 (paid) |
|
| **Bulk revocation** | ✗ | ✓ | Planned V3 (paid) |
|
||||||
| **Certificate health scores** | ✗ | ✓ | Planned V3 |
|
| **Certificate health scores** | ✗ | ✓ | Planned V3 |
|
||||||
| **Compliance scoring** | ✗ | ✓ | Planned V3 |
|
| **Compliance scoring** | ✗ | ✓ | Planned V3 |
|
||||||
| **DigiCert issuer** | ✗ | ✓ | Planned V3 |
|
| **DigiCert issuer** | ✗ | ✓ | Implemented (Beta) |
|
||||||
| **CT Log monitoring** | ✗ | ✓ | Planned V3 |
|
| **Vault PKI issuer** | ✗ | ✓ | Implemented (Beta) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# Migrate from acme.sh to certctl
|
||||||
|
|
||||||
|
You use acme.sh to automate Let's Encrypt renewal across multiple servers. It works — but without centralized visibility, deployment verification, or policy enforcement.
|
||||||
|
|
||||||
|
This guide walks through moving your acme.sh workload to certctl while keeping your existing DNS provider setup.
|
||||||
|
|
||||||
|
## Why Migrate
|
||||||
|
|
||||||
|
**acme.sh strength:** Lightweight agent, works everywhere, integrates with any DNS provider via shell script hooks.
|
||||||
|
|
||||||
|
**acme.sh limitations:**
|
||||||
|
- No inventory visibility — certificates scattered across servers, no unified view of expiry dates or renewal status
|
||||||
|
- No deployment verification — cron job succeeds even if cert doesn't actually take effect on the service
|
||||||
|
- No policy enforcement — no way to require approval, audit who renewed what, or prevent misconfigurations
|
||||||
|
- No multi-server orchestration — each server manages its own renewals; no way to batch test or rollback
|
||||||
|
|
||||||
|
certctl adds a control plane that sees all your certificates, deploys with verification, enforces policy, and provides a complete audit trail. You keep the DNS-01 challenge scripts you already have.
|
||||||
|
|
||||||
|
## What You Keep
|
||||||
|
|
||||||
|
- **Existing certificates** — discovered automatically during migration, claimed in the dashboard
|
||||||
|
- **DNS provider scripts** — acme.sh's `dns_*` hooks are shell-script compatible with certctl's DNS-01 implementation
|
||||||
|
- **Same Let's Encrypt account** — ACME issuer in certctl uses the same account and email
|
||||||
|
|
||||||
|
## Migration Steps
|
||||||
|
|
||||||
|
### 1. Deploy certctl Server
|
||||||
|
|
||||||
|
Start with Docker Compose (5 minutes):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/shankar0123/certctl.git
|
||||||
|
cd certctl/deploy
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Access the dashboard at `http://localhost:8443` with API key from `.env` file.
|
||||||
|
|
||||||
|
### 2. Deploy Agents
|
||||||
|
|
||||||
|
On each server running acme.sh certs, install the certctl agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://raw.githubusercontent.com/shankar0123/certctl/master/install-agent.sh | bash
|
||||||
|
# Prompted for server URL and API key
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download and install agent binary
|
||||||
|
wget https://github.com/shankar0123/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64
|
||||||
|
chmod +x certctl-agent-linux-amd64
|
||||||
|
sudo mv certctl-agent-linux-amd64 /usr/local/bin/certctl-agent
|
||||||
|
|
||||||
|
# Create systemd unit
|
||||||
|
sudo tee /etc/systemd/system/certctl-agent.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=certctl Agent
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/certctl-agent
|
||||||
|
Environment="CERTCTL_SERVER_URL=https://certctl.internal:8443"
|
||||||
|
Environment="CERTCTL_API_KEY=your-api-key-here"
|
||||||
|
Environment="CERTCTL_DISCOVERY_DIRS=~/.acme.sh"
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now certctl-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Discover Existing acme.sh Certificates
|
||||||
|
|
||||||
|
acme.sh stores certificates in `~/.acme.sh/<domain>/` (or `/etc/acme.sh/` if installed system-wide).
|
||||||
|
|
||||||
|
When you start the agent with `CERTCTL_DISCOVERY_DIRS` pointing to those directories, it scans for existing PEM/DER certificates and reports fingerprints to the control plane. The dashboard's **Discovery** page shows what was found.
|
||||||
|
|
||||||
|
Example agent systemd service (using home directory):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Environment="CERTCTL_DISCOVERY_DIRS=/home/user/.acme.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for system-wide acme.sh:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Environment="CERTCTL_DISCOVERY_DIRS=/etc/acme.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Claim Discovered Certificates
|
||||||
|
|
||||||
|
In the **Discovery** page:
|
||||||
|
1. Review the "Unmanaged" certificates found by the agent
|
||||||
|
2. Click **Claim** on each acme.sh certificate
|
||||||
|
3. Enter the managed certificate ID to link it (e.g., `mc-api-prod`)
|
||||||
|
|
||||||
|
Once claimed, the certificate appears in the main **Certificates** page with ownership, renewal history, and deployment status.
|
||||||
|
|
||||||
|
### 5. Create an ACME Issuer
|
||||||
|
|
||||||
|
In **Issuers** → **+ New Issuer:**
|
||||||
|
|
||||||
|
1. Select **ACME** from the issuer type grid
|
||||||
|
2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and config
|
||||||
|
|
||||||
|
Or configure via environment variables:
|
||||||
|
```bash
|
||||||
|
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
export CERTCTL_ACME_EMAIL=your-email@example.com # same as your acme.sh account
|
||||||
|
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Adapt Your DNS Provider Scripts
|
||||||
|
|
||||||
|
acme.sh uses `dns_*` hooks (e.g., `dns_cloudflare`) with predictable argument patterns. certctl's DNS-01 uses the same pattern, so your scripts often work with zero changes.
|
||||||
|
|
||||||
|
**acme.sh pattern:**
|
||||||
|
```bash
|
||||||
|
# acme.sh invokes: dns_cloudflare_add "domain" "record" "value"
|
||||||
|
dns_cloudflare_add() {
|
||||||
|
local full_domain=$1
|
||||||
|
local record_name=$2
|
||||||
|
local record_value=$3
|
||||||
|
# ... DNS API call to create TXT record ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**certctl pattern:**
|
||||||
|
```bash
|
||||||
|
# certctl invokes: /path/to/dns-present-script
|
||||||
|
# Scripts receive environment variables:
|
||||||
|
#!/bin/bash
|
||||||
|
# CERTCTL_DNS_DOMAIN — domain name (e.g., "example.com")
|
||||||
|
# CERTCTL_DNS_FQDN — full record name (e.g., "_acme-challenge.example.com")
|
||||||
|
# CERTCTL_DNS_VALUE — TXT record value (key authorization digest)
|
||||||
|
# CERTCTL_DNS_TOKEN — ACME challenge token
|
||||||
|
# Create TXT record at "${CERTCTL_DNS_FQDN}" with value "${CERTCTL_DNS_VALUE}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example: Cloudflare DNS-01 adapter**
|
||||||
|
|
||||||
|
If you have an acme.sh Cloudflare hook, adapt it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# /etc/certctl/dns/cloudflare-present.sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# certctl passes these environment variables:
|
||||||
|
# CERTCTL_DNS_DOMAIN — domain name
|
||||||
|
# CERTCTL_DNS_FQDN — full record name (e.g., "_acme-challenge.example.com")
|
||||||
|
# CERTCTL_DNS_VALUE — TXT record value
|
||||||
|
# CERTCTL_DNS_TOKEN — ACME challenge token
|
||||||
|
|
||||||
|
# Call your existing Cloudflare API (example using curl)
|
||||||
|
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
|
||||||
|
-H "X-Auth-Email: ${CF_EMAIL}" \
|
||||||
|
-H "X-Auth-Key: ${CF_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"type\":\"TXT\",\"name\":\"${CERTCTL_DNS_FQDN}\",\"content\":\"${CERTCTL_DNS_VALUE}\"}"
|
||||||
|
|
||||||
|
echo "Created ${CERTCTL_DNS_FQDN}"
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS cleanup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# /etc/certctl/dns/cloudflare-cleanup.sh
|
||||||
|
|
||||||
|
# certctl passes these environment variables:
|
||||||
|
# CERTCTL_DNS_DOMAIN — domain name
|
||||||
|
# CERTCTL_DNS_FQDN — full record name (e.g., "_acme-challenge.example.com")
|
||||||
|
# CERTCTL_DNS_VALUE — TXT record value
|
||||||
|
# CERTCTL_DNS_TOKEN — ACME challenge token
|
||||||
|
|
||||||
|
# Query and delete the TXT record
|
||||||
|
curl -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
|
||||||
|
-H "X-Auth-Email: ${CF_EMAIL}" \
|
||||||
|
-H "X-Auth-Key: ${CF_KEY}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure the ACME issuer via environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
export CERTCTL_ACME_EMAIL=your-email@example.com
|
||||||
|
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
|
||||||
|
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
|
||||||
|
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cloudflare-cleanup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or create the issuer through the dashboard: **Issuers** → **+ New Issuer** → select **ACME** → fill in the config fields.
|
||||||
|
|
||||||
|
### 7. Create Renewal Policies
|
||||||
|
|
||||||
|
In **Policies** → **+ New Policy:**
|
||||||
|
|
||||||
|
- **Name:** e.g., "ACME DNS-01 Policy"
|
||||||
|
- **Type:** `expiration_window` (enforces renewal thresholds)
|
||||||
|
- **Severity:** `high`
|
||||||
|
- **Config:** set your renewal window (default: 30 days before expiry)
|
||||||
|
|
||||||
|
Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails on top.
|
||||||
|
|
||||||
|
### 8. Phase Out acme.sh Cron
|
||||||
|
|
||||||
|
Once you verify renewals work via certctl (manually trigger one in the dashboard first), remove the acme.sh cron job:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove acme.sh from crontab
|
||||||
|
crontab -e
|
||||||
|
# Delete the line: "0 0 * * * /home/user/.acme.sh/acme.sh --cron --home /home/user/.acme.sh"
|
||||||
|
|
||||||
|
# OR disable the cron service if installed
|
||||||
|
sudo systemctl disable acme-renew.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
## DNS Script Compatibility
|
||||||
|
|
||||||
|
Most acme.sh DNS provider hooks need only minor changes:
|
||||||
|
|
||||||
|
| acme.sh | certctl |
|
||||||
|
|---------|---------|
|
||||||
|
| Called on every renewal | Called once per challenge window |
|
||||||
|
| Receives: domain, record name, record value as arguments | Receives: `CERTCTL_DNS_DOMAIN`, `CERTCTL_DNS_FQDN`, `CERTCTL_DNS_VALUE`, `CERTCTL_DNS_TOKEN` as environment variables |
|
||||||
|
| Must support multiple concurrent records | Same — cleanup removes the specific token |
|
||||||
|
| Environment variables for credentials | Same — pass via agent systemd `Environment=` or `.env` file |
|
||||||
|
|
||||||
|
**Real example:** If you use Route53, acme.sh's `dns_aws` hook submits via AWS CLI. Adapt it to use `${CERTCTL_DNS_FQDN}` and `${CERTCTL_DNS_VALUE}` environment variables instead of positional arguments, and it works with certctl's DNS-01.
|
||||||
|
|
||||||
|
## Coexistence Period
|
||||||
|
|
||||||
|
During migration, run both acme.sh and certctl in parallel:
|
||||||
|
|
||||||
|
1. Keep acme.sh cron running (low overhead, serves as fallback)
|
||||||
|
2. Configure certctl policies and test renewal on 1-2 non-critical domains
|
||||||
|
3. Monitor certctl's audit trail and deployment logs
|
||||||
|
4. Once confident, disable acme.sh cron on those domains
|
||||||
|
5. Roll out to remaining domains
|
||||||
|
|
||||||
|
This way, if certctl renewal fails, acme.sh's cron still renews the cert (you'll see duplicate renewals in the audit trail, but no gap).
|
||||||
|
|
||||||
|
## Next: DNS-PERSIST-01 (Zero-Touch Renewals)
|
||||||
|
|
||||||
|
After migrating to certctl + DNS-01, consider upgrading to **DNS-PERSIST-01**. Instead of creating/deleting DNS records on every renewal, you create one persistent TXT record at `_validation-persist.<domain>` that never changes. Let's Encrypt then validates against that standing record forever.
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- **Zero operational overhead per renewal** — no DNS API calls during renewal
|
||||||
|
- **Auditable** — DNS record created once, visible to the team, never modified
|
||||||
|
- **Vendor-agnostic** — works with any DNS provider that supports TXT records
|
||||||
|
|
||||||
|
To enable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CERTCTL_ACME_CHALLENGE_TYPE=dns-persist-01
|
||||||
|
export CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN=letsencrypt.org
|
||||||
|
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
See [Connector Configuration](connectors.md) for advanced ACME options (EAB, ARI, custom timeouts).
|
||||||
|
|
||||||
|
See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Migrating from Certbot to certctl
|
||||||
|
|
||||||
|
You have 50 Let's Encrypt certificates across 10 servers, managed by a mix of Certbot cron jobs and manual renewals. Certbot handles issuance, but you lack inventory visibility, centralized alerting, and audit trails. This guide walks you through moving to certctl while keeping your existing certificates and ACME account.
|
||||||
|
|
||||||
|
## Why Migrate
|
||||||
|
|
||||||
|
Certbot renews certs in isolation. If a renewal fails on one server, you don't know until the cert expires. certctl gives you a single pane of glass: see all certs across all servers, get alerts 30/14/7 days before expiry, track who renewed what when, and verify each deployment succeeded via TLS fingerprint validation.
|
||||||
|
|
||||||
|
## What You Keep
|
||||||
|
|
||||||
|
- Your existing Certbot ACME account key and Let's Encrypt account
|
||||||
|
- All issued certificates in `/etc/letsencrypt/live/`
|
||||||
|
- Certbot's renewal history and hooks
|
||||||
|
|
||||||
|
You will not re-issue any certificates. certctl discovers them and takes over renewal scheduling.
|
||||||
|
|
||||||
|
## Step-by-Step Migration
|
||||||
|
|
||||||
|
### 1. Deploy certctl Control Plane
|
||||||
|
|
||||||
|
Option A: Docker Compose (quickest for evaluation)
|
||||||
|
```bash
|
||||||
|
cd /opt/certctl
|
||||||
|
docker compose up -d
|
||||||
|
# Dashboard & API: http://localhost:8443
|
||||||
|
# Default API key in logs (grep CERTCTL_API_KEY docker logs certctl-server)
|
||||||
|
```
|
||||||
|
|
||||||
|
Option B: Kubernetes (Helm)
|
||||||
|
```bash
|
||||||
|
helm install certctl deploy/helm/certctl/ \
|
||||||
|
--set auth.apiKey=YOUR_SECURE_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Deploy Agents to Each Server
|
||||||
|
|
||||||
|
On each of your 10 servers running Certbot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux amd64 (adjust for your architecture)
|
||||||
|
curl -sSL https://github.com/shankar0123/certctl/releases/download/v2.1.0/certctl-agent-linux-amd64 \
|
||||||
|
-o /usr/local/bin/certctl-agent
|
||||||
|
chmod +x /usr/local/bin/certctl-agent
|
||||||
|
|
||||||
|
# Create config
|
||||||
|
sudo mkdir -p /etc/certctl /var/lib/certctl/keys
|
||||||
|
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
|
||||||
|
CERTCTL_SERVER_URL=http://certctl-control-plane.example.com:8443
|
||||||
|
CERTCTL_API_KEY=your-api-key-here
|
||||||
|
CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live
|
||||||
|
CERTCTL_KEY_DIR=/var/lib/certctl/keys
|
||||||
|
EOF
|
||||||
|
sudo chmod 600 /etc/certctl/agent.env
|
||||||
|
|
||||||
|
# Start agent
|
||||||
|
sudo systemctl start certctl-agent # if installed via script
|
||||||
|
# OR manually:
|
||||||
|
sudo certctl-agent --server https://... --api-key ... --discovery-dirs /etc/letsencrypt/live
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent will scan `/etc/letsencrypt/live/` and report all discovered certificates to the control plane.
|
||||||
|
|
||||||
|
### 3. Triage Discovered Certificates
|
||||||
|
|
||||||
|
In the certctl dashboard, go to **Discovery**:
|
||||||
|
- See all discovered certs grouped by agent
|
||||||
|
- Status shows "Unmanaged" for certificates not yet claimed
|
||||||
|
- For each Certbot cert, click **Claim** and link it to managed inventory
|
||||||
|
|
||||||
|
The control plane now knows about all 50 certs and where they live.
|
||||||
|
|
||||||
|
### 4. Configure ACME Issuer
|
||||||
|
|
||||||
|
Go to **Issuers** → **+ New Issuer**:
|
||||||
|
1. Select **ACME** from the issuer type grid
|
||||||
|
2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and any required config
|
||||||
|
|
||||||
|
Alternatively, configure via environment variables before starting the server:
|
||||||
|
```bash
|
||||||
|
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
export CERTCTL_ACME_EMAIL=your-email@example.com
|
||||||
|
export CERTCTL_ACME_CHALLENGE_TYPE=http-01 # or dns-01 for wildcard certs
|
||||||
|
```
|
||||||
|
|
||||||
|
For DNS-01, also set:
|
||||||
|
```bash
|
||||||
|
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/present.sh
|
||||||
|
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cleanup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
certctl uses the same Let's Encrypt account; no new credentials needed.
|
||||||
|
|
||||||
|
### 5. Create Renewal Policies
|
||||||
|
|
||||||
|
Go to **Policies** → **+ New Policy** to create enforcement rules:
|
||||||
|
- Name: e.g., "ACME Renewal Policy"
|
||||||
|
- Type: `expiration_window` (to enforce renewal thresholds)
|
||||||
|
- Severity: `high`
|
||||||
|
- Config: set your renewal threshold (default: 30 days before expiry)
|
||||||
|
|
||||||
|
Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails (key algorithm requirements, expiration windows, etc.).
|
||||||
|
|
||||||
|
### 6. Disable Certbot Cron, One Server at a Time
|
||||||
|
|
||||||
|
On the first server (start with a low-traffic one):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop Certbot renewal
|
||||||
|
sudo systemctl disable certbot.timer
|
||||||
|
sudo systemctl stop certbot.timer
|
||||||
|
|
||||||
|
# Or remove the cron job
|
||||||
|
sudo rm /etc/cron.d/certbot # if managed by cron
|
||||||
|
```
|
||||||
|
|
||||||
|
Monitor that server in the certctl dashboard. Certctl will renew the cert ~30 days before expiry.
|
||||||
|
|
||||||
|
### 7. Verify First Renewal Succeeds
|
||||||
|
|
||||||
|
Wait for the renewal to trigger (or manually trigger it in **Certificates** → select cert → **Renew**). Check the dashboard:
|
||||||
|
- **Certificates** page: status transitions from `Active` to `Renewing` to `Active`
|
||||||
|
- **Jobs** page: renewal job shows `Completed` status
|
||||||
|
- **Verification** tab: TLS check confirms the new cert is deployed and live
|
||||||
|
|
||||||
|
After verifying, disable Certbot on the remaining 9 servers.
|
||||||
|
|
||||||
|
### 8. Enable Alerting
|
||||||
|
|
||||||
|
Configure notifiers via environment variables before starting the server:
|
||||||
|
```bash
|
||||||
|
# Example: Slack alerting
|
||||||
|
export CERTCTL_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Or email alerting
|
||||||
|
export CERTCTL_SMTP_HOST=smtp.gmail.com
|
||||||
|
export CERTCTL_SMTP_PORT=587
|
||||||
|
export CERTCTL_SMTP_USERNAME=your-email@gmail.com
|
||||||
|
export CERTCTL_SMTP_PASSWORD=your-app-password
|
||||||
|
export CERTCTL_SMTP_FROM_ADDRESS=certctl@example.com
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Other options: CERTCTL_TEAMS_WEBHOOK_URL, CERTCTL_PAGERDUTY_ROUTING_KEY, CERTCTL_OPSGENIE_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you get 30/14/7-day warnings before any cert expires, across all 10 servers, in one place.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Renewal**: Agent polls certctl for work instead of Certbot cron triggering locally. Faster failure detection (agent heartbeat every 60 seconds vs. cron running once a day).
|
||||||
|
- **Deployment**: certctl verifies post-deployment by probing the live TLS endpoint and comparing SHA-256 fingerprints. Catches reload failures silently.
|
||||||
|
- **Audit Trail**: Every renewal, deployment, and alert is logged immutably. Answer "who renewed cert X when and why" within seconds.
|
||||||
|
- **Alerting**: Threshold-based alerts to Slack/email/webhook 30/14/7 days before expiry, not when cert expires.
|
||||||
|
|
||||||
|
## Coexistence and Rollback
|
||||||
|
|
||||||
|
During migration, certctl and Certbot can run simultaneously. The agent will discover Certbot certs even while Certbot continues renewing them. Run both for a week to build confidence.
|
||||||
|
|
||||||
|
**If you need to rollback**: Re-enable Certbot cron on any server:
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable certbot.timer
|
||||||
|
sudo systemctl start certbot.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
certctl will stop renewing that cert when the policy is disabled. Certbot resumes as before. Your certificates and ACME account remain untouched.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs)
|
||||||
|
- Explore [Network Discovery](./quickstart.md#network-discovery-agentless) to find certificates you didn't know about
|
||||||
|
- Set up [Kubernetes cert-manager integration](./certctl-for-cert-manager-users.md) if you manage in-cluster certs too
|
||||||
+61
-13
@@ -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
|
||||||
@@ -87,7 +105,7 @@ Open **http://localhost:8443** in your browser.
|
|||||||
>
|
>
|
||||||
> **Key rotation:** `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `CERTCTL_AUTH_SECRET=new-key,old-key`). Both keys are valid simultaneously, enabling zero-downtime rotation: add the new key, roll clients over, then remove the old key.
|
> **Key rotation:** `CERTCTL_AUTH_SECRET` accepts comma-separated keys (e.g., `CERTCTL_AUTH_SECRET=new-key,old-key`). Both keys are valid simultaneously, enabling zero-downtime rotation: add the new key, roll clients over, then remove the old key.
|
||||||
|
|
||||||
The dashboard comes pre-loaded with 15 demo certificates across multiple teams, environments, and statuses — expiring certs, expired certs, active certs, failed renewals. A realistic snapshot of what certificate management looks like in a real organization.
|
The dashboard comes pre-loaded with 35 demo certificates across 5 issuers, 8 agents, and 90 days of job history — expiring certs, expired certs, active certs, failed renewals, revocations, discovery scans, and approval workflows. A realistic snapshot of what certificate management looks like in a real organization.
|
||||||
|
|
||||||
### What you're looking at
|
### What you're looking at
|
||||||
|
|
||||||
@@ -109,7 +127,7 @@ Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifica
|
|||||||
|
|
||||||
**"I need to approve a renewal before it proceeds"** — Click "Jobs" in the sidebar. You'll see an amber banner: "2 jobs awaiting approval." These are renewal jobs for `auth-production` and `payments-production` that require human sign-off before proceeding. Click Approve or Reject with a reason — the decision is recorded in the audit trail.
|
**"I need to approve a renewal before it proceeds"** — Click "Jobs" in the sidebar. You'll see an amber banner: "2 jobs awaiting approval." These are renewal jobs for `auth-production` and `payments-production` that require human sign-off before proceeding. Click Approve or Reject with a reason — the decision is recorded in the audit trail.
|
||||||
|
|
||||||
**"Show me the agent fleet"** — Click "Agents." Four agents online, one offline. Click "Fleet Overview" for OS/architecture grouping, version distribution, and per-platform listing. Agents generate ECDSA P-256 keys locally — private keys never leave your infrastructure.
|
**"Show me the agent fleet"** — Click "Agents." Eight agents across Linux, macOS, and Windows platforms—most online, showing OS, architecture, IP, and version metadata. A ninth entry (server-scanner) is the sentinel agent used for network certificate discovery. Click "Fleet Overview" for OS/architecture grouping, version distribution, and per-platform listing. Agents generate ECDSA P-256 keys locally — private keys never leave your infrastructure.
|
||||||
|
|
||||||
**"What about bulk operations?"** — On the Certificates page, select multiple certificates with checkboxes. A bulk action bar appears: trigger renewal, revoke with reason codes, or reassign ownership — all with progress tracking. At 47-day lifespans with hundreds of certs, bulk operations aren't optional.
|
**"What about bulk operations?"** — On the Certificates page, select multiple certificates with checkboxes. A bulk action bar appears: trigger renewal, revoke with reason codes, or reassign ownership — all with progress tracking. At 47-day lifespans with hundreds of certs, bulk operations aren't optional.
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -363,18 +410,19 @@ Exposes 78 MCP tools covering the REST API via stdio transport. Ask Claude: "Wha
|
|||||||
|
|
||||||
| Resource | Count | Examples |
|
| Resource | Count | Examples |
|
||||||
|----------|-------|---------|
|
|----------|-------|---------|
|
||||||
| Teams | 5 | Platform, Security, Payments, Frontend, Data |
|
| Teams | 6 | Platform, Security, Payments, Frontend, Data, DevOps |
|
||||||
| Owners | 5 | Alice, Bob, Carol, Dave, Eve |
|
| Owners | 6 | Alice, Bob, Carol, Dave, Eve, Frank |
|
||||||
| Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) |
|
| Issuers | 5 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, ZeroSSL (EAB), Custom OpenSSL CA |
|
||||||
| Agents | 6 | ag-web-prod, ag-web-staging, ag-lb-prod, ag-iis-prod, ag-data-prod, server-scanner (network discovery) |
|
| Agents | 9 | 8 real agents (linux/darwin/windows, amd64/arm64) + server-scanner (network discovery) |
|
||||||
| Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS |
|
| Targets | 8 | NGINX prod, NGINX staging, NGINX data, HAProxy, Apache, IIS, Traefik, Caddy |
|
||||||
| Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard |
|
| Certificates | 35 | Active, Expiring, Expired, Failed, Revoked, RenewalInProgress, Wildcard, S/MIME |
|
||||||
| Discovered Certs | 9 | 5 Unmanaged (filesystem + network), 2 Managed (linked), 1 Dismissed, network-discovered expired printer cert |
|
| Jobs | 50+ | 90 days of issuance, renewal, deployment jobs + 2 AwaitingApproval |
|
||||||
| Discovery Scans | 3 | Agent filesystem scans + network TLS scan |
|
| Discovered Certs | 12 | Unmanaged (filesystem + network), Managed (linked), Dismissed |
|
||||||
| Network Scan Targets | 3 | DC1 Web Servers, DC2 Application Tier, DMZ Public Endpoints |
|
| Discovery Scans | 8 | Historical + recent agent filesystem scans + network TLS scans |
|
||||||
| Jobs (Approval) | 2 | AwaitingApproval renewal jobs for auth-prod and payments-prod |
|
| Network Scan Targets | 4 | DC1 Web Servers, DC2 Application Tier, DMZ Public Endpoints, Edge Locations |
|
||||||
|
| Audit Events | 55+ | 90 days of lifecycle events (issuance, renewal, deployment, revocation, discovery) |
|
||||||
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
|
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
|
||||||
| Profiles | 4 | Standard TLS, Internal mTLS, Short-Lived, High Security |
|
| Profiles | 5 | Standard TLS, Internal mTLS, Short-Lived, High Security, S/MIME Email |
|
||||||
| Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. |
|
| Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. |
|
||||||
|
|
||||||
## Dashboard Demo Mode
|
## Dashboard Demo Mode
|
||||||
|
|||||||
@@ -1,480 +0,0 @@
|
|||||||
# certctl Test Gap Attack Prompt
|
|
||||||
|
|
||||||
**Purpose:** Self-contained prompt for a future Claude session to systematically close all identified test gaps. Copy this entire document into a new session along with CLAUDE.md.
|
|
||||||
|
|
||||||
**Estimated effort:** 250-350 new test functions across 12-15 new/modified test files.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
You are working on certctl, a self-hosted certificate lifecycle platform. The project has ~1100 tests but a comprehensive audit identified 12 gaps across 4 priority tiers. Your job is to close ALL of them in order (P0 first, then P1, then P2). After each file you create or modify, run the specific test file to verify it passes, then run `go vet ./...` to catch issues early.
|
|
||||||
|
|
||||||
**Key conventions:**
|
|
||||||
- Package-level tests (e.g., `package service` not `package service_test`) so you can access unexported fields
|
|
||||||
- Mock repositories use function-field injection pattern (see `internal/service/testutil_test.go` for all mocks)
|
|
||||||
- Mocks available: `mockCertRepo`, `mockJobRepo`, `mockNotifRepo`, `mockAuditRepo`, `mockPolicyRepo`, `mockRenewalPolicyRepo`, `mockAgentRepo`, `mockTargetRepo`, `mockIssuerConnector`, `mockIssuerRepository`, `mockRevocationRepo`, `mockNotifier`
|
|
||||||
- Constructor helpers: `newMockCertificateRepository()`, `newMockJobRepository()`, etc.
|
|
||||||
- Test naming: `TestServiceName_MethodName_Scenario` (e.g., `TestDeploymentService_CreateDeploymentJobs_Success`)
|
|
||||||
- All tests use `context.Background()` unless testing cancellation
|
|
||||||
- The `generateID(prefix)` function exists in the service package for creating IDs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-1: `internal/service/deployment_test.go` (NEW FILE)
|
|
||||||
|
|
||||||
**File to test:** `internal/service/deployment.go`
|
|
||||||
|
|
||||||
Create `internal/service/deployment_test.go` in `package service`.
|
|
||||||
|
|
||||||
### DeploymentService struct dependencies:
|
|
||||||
```go
|
|
||||||
type DeploymentService struct {
|
|
||||||
jobRepo repository.JobRepository // mockJobRepo
|
|
||||||
targetRepo repository.TargetRepository // mockTargetRepo
|
|
||||||
agentRepo repository.AgentRepository // mockAgentRepo
|
|
||||||
certRepo repository.CertificateRepository // mockCertRepo
|
|
||||||
auditService *AuditService // real AuditService with mockAuditRepo
|
|
||||||
notificationSvc *NotificationService // real NotificationService with mockNotifRepo + mockNotifier
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Setup helper:
|
|
||||||
```go
|
|
||||||
func newTestDeploymentService() (*DeploymentService, *mockJobRepo, *mockTargetRepo, *mockAgentRepo, *mockCertRepo, *mockAuditRepo) {
|
|
||||||
jobRepo := newMockJobRepository()
|
|
||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
|
||||||
agentRepo := newMockAgentRepository()
|
|
||||||
certRepo := newMockCertificateRepository()
|
|
||||||
auditRepo := newMockAuditRepository()
|
|
||||||
auditSvc := NewAuditService(auditRepo)
|
|
||||||
notifRepo := newMockNotificationRepository()
|
|
||||||
notifier := newMockNotifier()
|
|
||||||
notifSvc := NewNotificationService(notifRepo, auditSvc)
|
|
||||||
notifSvc.RegisterNotifier(notifier)
|
|
||||||
|
|
||||||
svc := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditSvc, notifSvc)
|
|
||||||
return svc, jobRepo, targetRepo, agentRepo, certRepo, auditRepo
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required tests (~20 functions):
|
|
||||||
|
|
||||||
**CreateDeploymentJobs:**
|
|
||||||
1. `TestDeploymentService_CreateDeploymentJobs_Success` — 2 targets for cert, verify 2 jobs created with correct CertificateID, Type=Deployment, Status=Pending, TargetID set
|
|
||||||
2. `TestDeploymentService_CreateDeploymentJobs_NoTargets` — empty targets list, expect error "no targets found"
|
|
||||||
3. `TestDeploymentService_CreateDeploymentJobs_TargetListError` — targetRepo.ListByCertErr set, expect wrapped error
|
|
||||||
4. `TestDeploymentService_CreateDeploymentJobs_AllJobCreationsFail` — jobRepo.CreateErr set, expect error "failed to create any deployment jobs"
|
|
||||||
5. `TestDeploymentService_CreateDeploymentJobs_PartialFailure` — first job create fails (use a counter-based mock or accept that current mock fails all), verify at least error handling
|
|
||||||
6. `TestDeploymentService_CreateDeploymentJobs_AuditEvent` — verify auditRepo.Events contains "deployment_jobs_created" event with target_count and job_count
|
|
||||||
|
|
||||||
**ProcessDeploymentJob:**
|
|
||||||
7. `TestDeploymentService_ProcessDeploymentJob_Success` — job with TargetID, target has AgentID, agent has recent heartbeat. Verify job status updated to Running, audit event recorded
|
|
||||||
8. `TestDeploymentService_ProcessDeploymentJob_CertNotFound` — certRepo.GetErr set, verify job marked Failed
|
|
||||||
9. `TestDeploymentService_ProcessDeploymentJob_NoTargetID` — job.TargetID is nil, verify job marked Failed with "target_id not found"
|
|
||||||
10. `TestDeploymentService_ProcessDeploymentJob_TargetNotFound` — targetRepo.GetErr set, verify job marked Failed
|
|
||||||
11. `TestDeploymentService_ProcessDeploymentJob_AgentNotFound` — agentRepo.GetErr set, verify job marked Failed
|
|
||||||
12. `TestDeploymentService_ProcessDeploymentJob_AgentOffline` — agent.LastHeartbeatAt is 10 minutes ago, verify job marked Failed with "agent is offline", notification sent
|
|
||||||
|
|
||||||
**ValidateDeployment:**
|
|
||||||
13. `TestDeploymentService_ValidateDeployment_Completed` — deployment job exists with Status=Completed, expect (true, nil)
|
|
||||||
14. `TestDeploymentService_ValidateDeployment_Failed` — deployment job with Status=Failed and LastError, expect (false, error with message)
|
|
||||||
15. `TestDeploymentService_ValidateDeployment_InProgress` — deployment job with Status=Running, expect (false, "deployment in progress")
|
|
||||||
16. `TestDeploymentService_ValidateDeployment_NoJob` — no matching deployment job, expect (false, "no deployment job found")
|
|
||||||
17. `TestDeploymentService_ValidateDeployment_ListError` — jobRepo returns error
|
|
||||||
|
|
||||||
**MarkDeploymentComplete:**
|
|
||||||
18. `TestDeploymentService_MarkDeploymentComplete_Success` — verify job status -> Completed, notification sent (success=true), audit event
|
|
||||||
19. `TestDeploymentService_MarkDeploymentComplete_JobNotFound` — jobRepo.GetErr set
|
|
||||||
20. `TestDeploymentService_MarkDeploymentComplete_NoTargetID` — job.TargetID is nil, still completes without notification
|
|
||||||
|
|
||||||
**MarkDeploymentFailed:**
|
|
||||||
21. `TestDeploymentService_MarkDeploymentFailed_Success` — verify job status -> Failed, error message stored, notification sent (success=false), audit event
|
|
||||||
22. `TestDeploymentService_MarkDeploymentFailed_JobNotFound` — jobRepo.GetErr set
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-2: `internal/service/target_test.go` (NEW FILE)
|
|
||||||
|
|
||||||
**File to test:** `internal/service/target.go`
|
|
||||||
|
|
||||||
### Setup:
|
|
||||||
```go
|
|
||||||
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo) {
|
|
||||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
|
||||||
auditRepo := newMockAuditRepository()
|
|
||||||
auditSvc := NewAuditService(auditRepo)
|
|
||||||
return NewTargetService(targetRepo, auditSvc), targetRepo, auditRepo
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required tests (~15 functions):
|
|
||||||
|
|
||||||
**Context-aware methods (List, Get, Create, Update, Delete):**
|
|
||||||
1. `TestTargetService_List_Success` — 3 targets, page=1 perPage=2, expect 2 returned with total=3
|
|
||||||
2. `TestTargetService_List_DefaultPagination` — page=0 perPage=0, expect defaults to 1/50
|
|
||||||
3. `TestTargetService_List_EmptyPage` — page=2 perPage=10 with only 3 targets, expect empty slice, total=3
|
|
||||||
4. `TestTargetService_List_RepoError` — ListErr set
|
|
||||||
5. `TestTargetService_Get_Success` — target exists
|
|
||||||
6. `TestTargetService_Get_NotFound` — target doesn't exist
|
|
||||||
7. `TestTargetService_Create_Success` — verify target stored, ID generated, timestamps set, audit event
|
|
||||||
8. `TestTargetService_Create_MissingName` — empty name, expect error
|
|
||||||
9. `TestTargetService_Create_RepoError` — CreateErr set
|
|
||||||
10. `TestTargetService_Update_Success` — verify target updated, audit event
|
|
||||||
11. `TestTargetService_Update_MissingName` — empty name, expect error
|
|
||||||
12. `TestTargetService_Delete_Success` — verify target removed, audit event
|
|
||||||
13. `TestTargetService_Delete_RepoError` — DeleteErr set
|
|
||||||
|
|
||||||
**Legacy handler interface methods:**
|
|
||||||
14. `TestTargetService_ListTargets_Success` — verify returns dereferenced targets
|
|
||||||
15. `TestTargetService_GetTarget_Success`
|
|
||||||
16. `TestTargetService_CreateTarget_Success` — verify ID generation
|
|
||||||
17. `TestTargetService_UpdateTarget_Success`
|
|
||||||
18. `TestTargetService_DeleteTarget_Success`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-3: Scheduler Loop Execution Tests
|
|
||||||
|
|
||||||
**File to modify:** `internal/scheduler/scheduler_test.go`
|
|
||||||
|
|
||||||
The existing tests cover idempotency and graceful shutdown. Add tests that verify each loop actually calls its service method.
|
|
||||||
|
|
||||||
### Required tests (~8 functions):
|
|
||||||
|
|
||||||
1. `TestSchedulerRenewalLoopCallsService` — start scheduler with 50ms interval, wait 150ms, verify renewalMock.callCount >= 1
|
|
||||||
2. `TestSchedulerJobProcessorLoopCallsService` — same pattern for jobMock
|
|
||||||
3. `TestSchedulerAgentHealthCheckLoopCallsService` — same for agentMock
|
|
||||||
4. `TestSchedulerNotificationLoopCallsService` — same for notificationMock
|
|
||||||
5. `TestSchedulerNetworkScanLoopCallsService` — same for networkMock
|
|
||||||
6. `TestSchedulerShortLivedExpiryLoopCallsService` — verify ExpireShortLivedCertificates is called (need to add callCount tracking to mockRenewalService.ExpireShortLivedCertificates)
|
|
||||||
7. `TestSchedulerLoopErrorRecovery` — set shouldError=true on renewalMock, verify scheduler continues (doesn't crash), subsequent calls still happen
|
|
||||||
8. `TestSchedulerLoopContextCancellation` — cancel context mid-execution, verify no panics, WaitForCompletion succeeds
|
|
||||||
|
|
||||||
**Note:** You'll need to add `expireCallCount` and `expireCallTimes` fields to `mockRenewalService` and track calls in `ExpireShortLivedCertificates`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P0-4: Agent Binary Tests
|
|
||||||
|
|
||||||
**File to create:** `cmd/agent/agent_test.go` (NEW FILE, `package main`)
|
|
||||||
|
|
||||||
This is the hardest gap. The agent binary's methods (`executeCSRJob`, `executeDeploymentJob`, heartbeat loop, discovery loop) need a mock HTTP server.
|
|
||||||
|
|
||||||
### Setup:
|
|
||||||
```go
|
|
||||||
func newTestServer(t *testing.T) *httptest.Server {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
// Register mock endpoints
|
|
||||||
mux.HandleFunc("/api/v1/agents/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Handle heartbeat (POST /agents/{id}/heartbeat), work (GET /agents/{id}/work),
|
|
||||||
// CSR submission (POST /agents/{id}/csr), job status (POST /agents/{id}/jobs/{job_id}/status),
|
|
||||||
// discoveries (POST /agents/{id}/discoveries)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
|
|
||||||
})
|
|
||||||
return httptest.NewServer(mux)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required tests (~10 functions):
|
|
||||||
|
|
||||||
1. `TestAgentHeartbeat_Success` — mock server returns 200, verify request has correct headers
|
|
||||||
2. `TestAgentHeartbeat_ServerDown` — connection refused, verify error handling (no panic)
|
|
||||||
3. `TestAgentCSRGeneration` — verify ECDSA P-256 key generation, CSR contains correct CN and SANs
|
|
||||||
4. `TestAgentCSRGeneration_EmailSAN` — verify email SANs route to EmailAddresses (not DNSNames)
|
|
||||||
5. `TestAgentWorkPolling_NoWork` — server returns empty work list
|
|
||||||
6. `TestAgentWorkPolling_DeploymentJob` — server returns deployment work item
|
|
||||||
7. `TestAgentWorkPolling_CSRJob` — server returns AwaitingCSR work item
|
|
||||||
8. `TestAgentKeyStorage` — verify keys written to temp dir with 0600 permissions
|
|
||||||
9. `TestAgentDiscoveryScan` — scan a temp directory with a test PEM file, verify correct extraction
|
|
||||||
10. `TestAgentDiscoveryScan_EmptyDir` — scan empty directory, verify empty results (no error)
|
|
||||||
|
|
||||||
**Important:** The agent code uses global variables and `main()` package patterns. You may need to extract testable functions or use `TestMain` for setup. If the agent's methods are on a struct, mock the HTTP client. If they're standalone functions, use httptest.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P1-1: `CompleteAgentCSRRenewal` Tests
|
|
||||||
|
|
||||||
**File to modify:** `internal/service/renewal_test.go`
|
|
||||||
|
|
||||||
### Required tests (~8 functions):
|
|
||||||
|
|
||||||
The method signature is:
|
|
||||||
```go
|
|
||||||
func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domain.Job, cert *domain.ManagedCertificate, csrPEM string) error
|
|
||||||
```
|
|
||||||
|
|
||||||
You need a `RenewalService` with: certRepo, jobRepo, auditService, notificationSvc, issuerConnector (mock), profileRepo (mock), keygenMode="agent".
|
|
||||||
|
|
||||||
1. `TestCompleteAgentCSRRenewal_Success` — valid job (AwaitingCSR), valid cert, valid CSR PEM. Verify: issuer.IssueCertificate called, cert version created, job status -> Completed, deployment jobs created
|
|
||||||
2. `TestCompleteAgentCSRRenewal_IssuerError` — issuerConnector.Err set, verify job status -> Failed
|
|
||||||
3. `TestCompleteAgentCSRRenewal_InvalidCSR` — garbage CSR PEM, verify error
|
|
||||||
4. `TestCompleteAgentCSRRenewal_WithEKUs` — cert has certificate_profile_id, profile has allowed_ekus=["emailProtection"], verify EKUs forwarded to issuer
|
|
||||||
5. `TestCompleteAgentCSRRenewal_NoProfile` — cert has no profile ID, verify default EKUs (nil)
|
|
||||||
6. `TestCompleteAgentCSRRenewal_CreateVersionError` — certRepo.CreateVersionErr set
|
|
||||||
7. `TestCompleteAgentCSRRenewal_AuditRecorded` — verify audit event with correct details
|
|
||||||
8. `TestCompleteAgentCSRRenewal_DeploymentJobsCreated` — after successful signing, verify deployment jobs exist in jobRepo
|
|
||||||
|
|
||||||
**Note:** You'll need a `mockProfileRepo` if one doesn't exist in testutil_test.go. Check if `internal/repository/interfaces.go` has `ProfileRepository` and create a mock.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P1-2: `ExpireShortLivedCertificates` Tests
|
|
||||||
|
|
||||||
**File to modify:** `internal/service/renewal_test.go`
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *RenewalService) ExpireShortLivedCertificates(ctx context.Context) error
|
|
||||||
```
|
|
||||||
|
|
||||||
1. `TestExpireShortLivedCertificates_NoShortLived` — no active certs with short-lived profiles, no changes
|
|
||||||
2. `TestExpireShortLivedCertificates_ExpiresActiveCert` — cert with profile TTL < 1h, cert active, cert's NotAfter is in the past. Verify status -> Expired
|
|
||||||
3. `TestExpireShortLivedCertificates_SkipsNonExpired` — cert with short-lived profile but NotAfter is in the future, no change
|
|
||||||
4. `TestExpireShortLivedCertificates_SkipsNonShortLived` — cert with normal profile (TTL > 1h), even if expired. Verify not touched by this method
|
|
||||||
5. `TestExpireShortLivedCertificates_RepoError` — certRepo.ListErr set
|
|
||||||
|
|
||||||
**Note:** This method needs access to profiles to determine TTL. Read the actual implementation to understand how it queries — it may iterate all active certs and check their profile's max_ttl.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P1-3: Domain Model Tests
|
|
||||||
|
|
||||||
### `internal/domain/job_test.go` (NEW FILE)
|
|
||||||
|
|
||||||
```go
|
|
||||||
package domain
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
```
|
|
||||||
|
|
||||||
1. `TestJobType_Constants` — verify all 4 JobType constants have expected string values
|
|
||||||
2. `TestJobStatus_Constants` — verify all 7 JobStatus constants
|
|
||||||
3. `TestVerificationStatus_Constants` — verify all 4 VerificationStatus constants (pending, success, failed, skipped)
|
|
||||||
|
|
||||||
### `internal/domain/certificate_test.go` (NEW FILE)
|
|
||||||
|
|
||||||
1. `TestCertificateStatus_Constants` — verify all 8 CertificateStatus constants
|
|
||||||
2. `TestRenewalPolicy_EffectiveAlertThresholds_Custom` — policy with custom thresholds returns them
|
|
||||||
3. `TestRenewalPolicy_EffectiveAlertThresholds_Default` — policy with nil thresholds returns DefaultAlertThresholds()
|
|
||||||
4. `TestDefaultAlertThresholds` — returns [30, 14, 7, 0]
|
|
||||||
|
|
||||||
### `internal/domain/agent_group_test.go` (NEW FILE)
|
|
||||||
|
|
||||||
1. `TestAgentGroup_HasDynamicCriteria_True` — group with MatchOS set
|
|
||||||
2. `TestAgentGroup_HasDynamicCriteria_False` — all criteria empty
|
|
||||||
3. `TestAgentGroup_MatchesAgent_AllMatch` — all 4 criteria set, agent matches all
|
|
||||||
4. `TestAgentGroup_MatchesAgent_OSMismatch` — MatchOS="linux", agent.OS="windows"
|
|
||||||
5. `TestAgentGroup_MatchesAgent_ArchMismatch` — MatchArchitecture="amd64", agent.Architecture="arm64"
|
|
||||||
6. `TestAgentGroup_MatchesAgent_VersionMismatch` — MatchVersion="1.0", agent.Version="2.0"
|
|
||||||
7. `TestAgentGroup_MatchesAgent_IPMismatch` — MatchIPCIDR doesn't match agent.IPAddress
|
|
||||||
8. `TestAgentGroup_MatchesAgent_EmptyCriteriaMatchesAll` — all criteria empty, any agent matches
|
|
||||||
9. `TestAgentGroup_MatchesAgent_PartialCriteria` — only MatchOS set, agent matches OS, other fields irrelevant
|
|
||||||
10. `TestAgentGroup_MatchesAgent_NilAgent` — if agent is nil, should not panic (add nil guard or verify behavior)
|
|
||||||
|
|
||||||
### `internal/domain/notification_test.go` (NEW FILE)
|
|
||||||
|
|
||||||
1. `TestNotificationType_Constants` — verify all 7 types
|
|
||||||
2. `TestNotificationChannel_Constants` — verify all 6 channels
|
|
||||||
3. `TestNotificationEvent_ZeroValue` — default struct has empty strings, nil pointers
|
|
||||||
|
|
||||||
### `internal/domain/policy_test.go` (NEW FILE)
|
|
||||||
|
|
||||||
1. `TestPolicyType_Constants` — verify all 5 policy types
|
|
||||||
2. `TestPolicySeverity_Constants` — verify all 3 severities
|
|
||||||
3. `TestPolicyViolation_Fields` — create a violation, verify all fields accessible
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P1-4: Handler Gap Tests
|
|
||||||
|
|
||||||
### Modify `internal/api/handler/agent_group_handler_test.go`
|
|
||||||
|
|
||||||
Add:
|
|
||||||
1. `TestUpdateAgentGroup_Success` — PUT with valid body, verify 200
|
|
||||||
2. `TestUpdateAgentGroup_InvalidJSON` — malformed body, verify 400
|
|
||||||
3. `TestUpdateAgentGroup_MissingName` — empty name field, verify 400
|
|
||||||
4. `TestUpdateAgentGroup_NotFound` — service returns not found error, verify 404
|
|
||||||
|
|
||||||
### Modify `internal/api/handler/issuer_handler_test.go`
|
|
||||||
|
|
||||||
Add:
|
|
||||||
1. `TestUpdateIssuer_Success` — PUT with valid body, verify 200
|
|
||||||
2. `TestUpdateIssuer_InvalidJSON` — verify 400
|
|
||||||
3. `TestUpdateIssuer_NotFound` — verify 404
|
|
||||||
|
|
||||||
### Modify `internal/api/handler/network_scan_handler_test.go`
|
|
||||||
|
|
||||||
Add:
|
|
||||||
1. `TestGetNetworkScanTarget_Success` — GET by ID, verify 200
|
|
||||||
2. `TestGetNetworkScanTarget_NotFound` — verify 404
|
|
||||||
3. `TestUpdateNetworkScanTarget_Success` — PUT with valid body, verify 200
|
|
||||||
4. `TestUpdateNetworkScanTarget_InvalidJSON` — verify 400
|
|
||||||
5. `TestUpdateNetworkScanTarget_NotFound` — verify 404
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P2-1: Frontend Error Handling Tests
|
|
||||||
|
|
||||||
**File to modify:** `web/src/api/client.test.ts`
|
|
||||||
|
|
||||||
Add error scenario tests for the 65+ API functions that lack them. Group by resource:
|
|
||||||
|
|
||||||
### Pattern:
|
|
||||||
```typescript
|
|
||||||
it('listCertificates handles 500 error', async () => {
|
|
||||||
fetchMock.mockResponseOnce('', { status: 500 });
|
|
||||||
await expect(listCertificates()).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getCertificate handles 404 error', async () => {
|
|
||||||
fetchMock.mockResponseOnce('', { status: 404 });
|
|
||||||
await expect(getCertificate('nonexistent')).rejects.toThrow();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required (~40 tests):
|
|
||||||
|
|
||||||
Add at minimum a 500 error test and a 404 test (where applicable) for each resource group:
|
|
||||||
- Certificates (list 500, get 404, renew 404, revoke 404, export 404)
|
|
||||||
- Agents (list 500, get 404)
|
|
||||||
- Jobs (list 500, get 404, cancel 404, approve 404, reject 404)
|
|
||||||
- Policies (list 500, get 404, create 400, update 404, delete 404)
|
|
||||||
- Profiles (list 500, get 404, create 400)
|
|
||||||
- Owners (list 500, get 404)
|
|
||||||
- Teams (list 500, get 404)
|
|
||||||
- Agent Groups (list 500, get 404)
|
|
||||||
- Issuers (list 500, get 404)
|
|
||||||
- Targets (list 500, get 404, create 400)
|
|
||||||
- Discovery (list 500, claim 404, dismiss 404)
|
|
||||||
- Network Scans (list 500, create 400, trigger 404)
|
|
||||||
- Stats/Metrics (500 errors)
|
|
||||||
- Health (500 error)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P2-2: Context Cancellation Tests
|
|
||||||
|
|
||||||
**File to create:** `internal/service/context_test.go` (NEW FILE)
|
|
||||||
|
|
||||||
Test that long-running service methods respect context cancellation.
|
|
||||||
|
|
||||||
### Pattern:
|
|
||||||
```go
|
|
||||||
func TestDeploymentService_CreateDeploymentJobs_ContextCancelled(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel() // Cancel immediately
|
|
||||||
|
|
||||||
svc, _, targetRepo, _, _, _ := newTestDeploymentService()
|
|
||||||
targetRepo.AddTarget(&domain.DeploymentTarget{ID: "t1", Name: "test"})
|
|
||||||
|
|
||||||
_, err := svc.CreateDeploymentJobs(ctx, "cert-1")
|
|
||||||
// Depending on implementation, may get context.Canceled or proceed normally
|
|
||||||
// The key assertion: no panic, no goroutine leak
|
|
||||||
t.Logf("result with cancelled context: %v", err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required (~8 tests):
|
|
||||||
|
|
||||||
1. `TestDeploymentService_ProcessDeploymentJob_ContextTimeout` — context with 1ms timeout
|
|
||||||
2. `TestNetworkScanService_ScanAllTargets_ContextCancelled` — cancel mid-scan
|
|
||||||
3. `TestDiscoveryService_ProcessDiscoveryReport_ContextCancelled`
|
|
||||||
4. `TestESTService_SimpleEnroll_ContextCancelled`
|
|
||||||
5. `TestExportService_ExportPKCS12_ContextCancelled`
|
|
||||||
6. `TestRenewalService_ProcessRenewalJob_ContextTimeout`
|
|
||||||
7. `TestCertificateService_RevokeCertificateWithActor_ContextCancelled`
|
|
||||||
8. `TestVerificationService_RecordVerificationResult_ContextCancelled`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P2-3: Concurrent Operation Tests
|
|
||||||
|
|
||||||
**File to create:** `internal/service/concurrent_test.go` (NEW FILE)
|
|
||||||
|
|
||||||
Use `sync.WaitGroup` and goroutines to test concurrent access patterns.
|
|
||||||
|
|
||||||
### Required (~6 tests):
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestConcurrentRevocation(t *testing.T) {
|
|
||||||
// Setup service with a certificate
|
|
||||||
// Launch 5 goroutines all trying to revoke the same cert simultaneously
|
|
||||||
// Verify: exactly 1 succeeds (or all succeed idempotently), no panics, no data corruption
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
errors := make([]error, 5)
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(idx int) {
|
|
||||||
defer wg.Done()
|
|
||||||
errors[idx] = svc.RevokeCertificateWithActor(ctx, certID, "keyCompromise", "test-actor")
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
// Assert at most 1 "already revoked" error
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
1. `TestConcurrentRevocation` — 5 goroutines revoke same cert
|
|
||||||
2. `TestConcurrentDeploymentJobCreation` — 3 goroutines create deployment jobs for same cert
|
|
||||||
3. `TestConcurrentDiscoveryReports` — 3 goroutines submit discovery reports simultaneously
|
|
||||||
4. `TestConcurrentCertificateList` — 10 goroutines list certificates simultaneously (no race)
|
|
||||||
5. `TestConcurrentJobStatusUpdate` — 5 goroutines update same job status
|
|
||||||
6. `TestConcurrentTargetCRUD` — create, update, delete targets concurrently
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution Order
|
|
||||||
|
|
||||||
Run these in order, verifying each step:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# P0 — Critical
|
|
||||||
go test ./internal/service/ -run TestDeploymentService -v -count=1
|
|
||||||
go test ./internal/service/ -run TestTargetService -v -count=1
|
|
||||||
go test ./internal/scheduler/ -run TestScheduler -v -count=1
|
|
||||||
|
|
||||||
# P1 — High Priority
|
|
||||||
go test ./internal/service/ -run TestCompleteAgentCSR -v -count=1
|
|
||||||
go test ./internal/service/ -run TestExpireShortLived -v -count=1
|
|
||||||
go test ./internal/domain/ -v -count=1
|
|
||||||
go test ./internal/api/handler/ -run "TestUpdateAgentGroup|TestUpdateIssuer|TestGetNetworkScan|TestUpdateNetworkScan" -v -count=1
|
|
||||||
|
|
||||||
# P2 — Medium Priority
|
|
||||||
cd web && npx vitest run
|
|
||||||
go test ./internal/service/ -run TestContext -v -count=1
|
|
||||||
go test ./internal/service/ -run TestConcurrent -v -count=1
|
|
||||||
|
|
||||||
# Full suite verification
|
|
||||||
go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/domain/... ./internal/validation/... -count=1 -timeout 300s
|
|
||||||
go vet ./...
|
|
||||||
cd web && npx vitest run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Final CI Gate
|
|
||||||
|
|
||||||
After all tests pass locally, verify the full CI pipeline would pass:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Coverage check
|
|
||||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... -count=1 -cover -coverprofile=coverage.out
|
|
||||||
|
|
||||||
# Check thresholds
|
|
||||||
go tool cover -func=coverage.out | grep 'internal/service' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {printf "Service: %.1f%%\n", sum/n}'
|
|
||||||
go tool cover -func=coverage.out | grep 'internal/api/handler' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {printf "Handler: %.1f%%\n", sum/n}'
|
|
||||||
go tool cover -func=coverage.out | grep 'internal/domain' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {printf "Domain: %.1f%%\n", sum/n}'
|
|
||||||
|
|
||||||
# Targets: service >= 60%, handler >= 60%, domain >= 40%
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What NOT To Do
|
|
||||||
|
|
||||||
- Do NOT modify any production code (only test files)
|
|
||||||
- Do NOT add new dependencies to go.mod
|
|
||||||
- Do NOT create mocks that duplicate existing ones in testutil_test.go — reuse them
|
|
||||||
- Do NOT use `testing.Short()` skips — all these tests should run in CI
|
|
||||||
- Do NOT use `time.Sleep` for synchronization — use channels, WaitGroups, or atomic counters
|
|
||||||
- Do NOT write tests that are flaky due to timing — if testing scheduler loops, use generous timeouts and verify "at least 1 call" rather than exact counts
|
|
||||||
+1121
-39
File diff suppressed because it is too large
Load Diff
-481
@@ -1,481 +0,0 @@
|
|||||||
# certctl Test Suite Audit & Manual Testing Guide
|
|
||||||
|
|
||||||
Last updated: March 28, 2026
|
|
||||||
|
|
||||||
This document covers the automated test suite inventory, identified gaps, and a complete manual testing guide for v2.1 release validation.
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
1. [Automated Test Suite Inventory](#automated-test-suite-inventory)
|
|
||||||
2. [Test Gap Analysis](#test-gap-analysis)
|
|
||||||
3. [Manual Testing Guide](#manual-testing-guide)
|
|
||||||
4. [Pre-Release Checklist](#pre-release-checklist)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Automated Test Suite Inventory
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
|
|
||||||
| Layer | Test Files | Test Functions | Subtests | Coverage Target | Notes |
|
|
||||||
|-------|-----------|---------------|----------|-----------------|-------|
|
|
||||||
| Service | 12 | ~120 | ~185 | 60% (CI gate) | Best-covered layer |
|
|
||||||
| Handler | 12 | ~140 | ~145 | 60% (CI gate) | Near-complete endpoint coverage |
|
|
||||||
| Domain | 3 | ~16 | ~12 | 40% (CI gate) | Only revocation, discovery, verification tested |
|
|
||||||
| Middleware | 2 | ~14 | ~10 | 50% (CI gate) | Audit + CORS tested |
|
|
||||||
| Integration | 2 | ~15 | ~25 | — | Lifecycle + negative paths |
|
|
||||||
| Connector (Issuer) | 4 | ~41 | — | — | Local CA, ACME DNS, step-ca, OpenSSL |
|
|
||||||
| Connector (Target) | 2 | ~12 | — | — | Traefik, Caddy |
|
|
||||||
| Connector (Notifier) | 4 | ~20 | — | — | Slack, Teams, PagerDuty, OpsGenie |
|
|
||||||
| Validation | 2 | ~10 | ~80 | — | command.go + fuzz tests |
|
|
||||||
| Scheduler | 1 | ~5 | — | — | Startup/shutdown only |
|
|
||||||
| CLI | 1 | ~14 | — | — | All 10 subcommands |
|
|
||||||
| Repository | 2 | ~24 | ~50 | — | testcontainers-go, skipped in CI |
|
|
||||||
| Agent | 1 | ~5 | — | — | verify.go only |
|
|
||||||
| Frontend (API) | 1 | 89 | — | — | 96% API function coverage |
|
|
||||||
| Frontend (Utils) | 1 | 18 | — | — | 100% utility coverage |
|
|
||||||
| **Total** | **~50** | **~835+** | **~200+** | — | **1100+ total test points** |
|
|
||||||
|
|
||||||
### CI Pipeline
|
|
||||||
|
|
||||||
Every push runs (`.github/workflows/ci.yml`):
|
|
||||||
|
|
||||||
- `go vet ./...`
|
|
||||||
- `golangci-lint` (11 linters including gosec, bodyclose, errcheck)
|
|
||||||
- `govulncheck` (dependency CVE scanning)
|
|
||||||
- `go test -race` (race detection across service, handler, middleware, scheduler, connector, domain, validation)
|
|
||||||
- `go test -cover` with per-layer thresholds (service 55%, handler 60%, domain 40%, middleware 30%)
|
|
||||||
- Frontend: `tsc --noEmit`, `vitest run`, `vite build`
|
|
||||||
|
|
||||||
### What's Well-Tested
|
|
||||||
|
|
||||||
**Service layer** — renewal flows (server + agent keygen modes), revocation (all 8 RFC 5280 reasons), CRL/OCSP generation, discovery (process report, claim, dismiss, summary), network scan (CIDR expansion, validation, CRUD), stats (5 aggregations), EST enrollment (GetCACerts, SimpleEnroll/ReEnroll, CSRAttrs), export (PEM split, PKCS#12 encoding), verification (record/get results), issuer adapter (issue, renew, revoke with EKU forwarding).
|
|
||||||
|
|
||||||
**Handler layer** — all 12 resource handlers tested with success paths, 404/400/405/500 error paths, input validation (required fields, type checks, JSON parsing), query parameter parsing (pagination, filters, sort, cursor, sparse fields). CRUD endpoints, revocation, CRL, OCSP, EST, export, verification handlers all covered.
|
|
||||||
|
|
||||||
**Connectors** — Local CA (self-signed, sub-CA with RSA/ECDSA, renewal, config validation), ACME DNS solver (present, cleanup, DNS-PERSIST-01), step-ca (issue, renew, revoke via mock HTTP), OpenSSL (config validation, script execution, timeout), Traefik (file write, directory validation), Caddy (API mode, file mode, config validation), all 4 notifiers (webhook payloads, HTTP errors, auth headers, config defaults).
|
|
||||||
|
|
||||||
**Validation** — shell injection prevention with 80+ adversarial patterns (fuzz tests), domain validation, ACME token validation.
|
|
||||||
|
|
||||||
**Frontend** — 107 Vitest tests: all API client functions (certificates, agents, jobs, policies, profiles, owners, teams, agent groups, discovery, network scans, stats, metrics, export, health), utility functions (date formatting, time-ago, expiry color), both happy path and some error scenarios.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Gap Analysis
|
|
||||||
|
|
||||||
### P0 — Critical Gaps (Production Risk)
|
|
||||||
|
|
||||||
**1. No tests for `service/deployment.go`** — deployment orchestration (creating deployment jobs, target resolution, deployment execution) is completely untested. This is the core path that actually puts certificates onto servers.
|
|
||||||
- Missing: `CreateDeploymentJobs`, `ProcessDeploymentJob`, target connector dispatch
|
|
||||||
- Risk: silent deployment failures, wrong cert deployed to wrong target
|
|
||||||
- Effort: 15-20 test functions, 1-2 days
|
|
||||||
|
|
||||||
**2. Agent binary (`cmd/agent/main.go`) largely untested** — only `verify.go` has tests. The agent's registration, heartbeat loop, work polling, CSR generation, discovery scanning, and deployment execution have no automated tests.
|
|
||||||
- Missing: heartbeat error handling, CSR generation edge cases, deployment with local keys, discovery scan error paths
|
|
||||||
- Risk: agent fails silently in production, key material handling bugs
|
|
||||||
- Effort: significant — needs mock control plane HTTP server, 3-5 days
|
|
||||||
- Mitigation: the manual testing guide below covers these flows
|
|
||||||
|
|
||||||
**3. `service/target.go` untested** — target CRUD operations (Create, List, Get, Update, Delete) have service-layer tests missing.
|
|
||||||
- Risk: target configuration errors not caught
|
|
||||||
- Effort: 8-10 test functions, 0.5 days
|
|
||||||
|
|
||||||
**4. Scheduler loop execution untested** — `scheduler_test.go` only tests startup and graceful shutdown. The 6 actual loops (renewal check, job processing, health check, notifications, short-lived expiry, network scanning) are not tested for correct execution behavior.
|
|
||||||
- Risk: scheduler silently stops processing without detection
|
|
||||||
- Effort: complex — needs time manipulation and mock services, 2-3 days
|
|
||||||
|
|
||||||
### P1 — High-Priority Gaps
|
|
||||||
|
|
||||||
**5. `CompleteAgentCSRRenewal()` not tested** — this is the critical path where agent-submitted CSRs are signed by the issuer. EKU resolution from profiles, deployment job creation after signing, and CSR validation are all untested at the service layer.
|
|
||||||
- Effort: 5-8 test functions, 1 day
|
|
||||||
|
|
||||||
**6. `ExpireShortLivedCertificates()` not tested** — scheduler operation that marks short-lived certs as expired. No test coverage.
|
|
||||||
- Effort: 3-4 test functions, 0.5 days
|
|
||||||
|
|
||||||
**7. Domain models mostly untested** — only `revocation.go`, `discovery.go`, and `verification.go` have test files. Missing: `job.go` (state machine transitions), `certificate.go` (status validation), `agent_group.go` (MatchesAgent criteria), `notification.go`, `policy.go`.
|
|
||||||
- Effort: 20-30 test functions across 5 files, 2-3 days
|
|
||||||
|
|
||||||
**8. Handler gaps** — `UpdateAgentGroup`, `UpdateIssuer`, `GetNetworkScanTarget`, `UpdateNetworkScanTarget` are untested handler methods.
|
|
||||||
- Effort: ~12 test functions, 0.5 days
|
|
||||||
|
|
||||||
### P2 — Medium-Priority Gaps
|
|
||||||
|
|
||||||
**9. Frontend: zero component/page render tests** — no React component tests exist. All 22 pages and 8 shared components are untested for rendering, user interaction, modal behavior, and form validation.
|
|
||||||
- Risk: UI regressions go undetected
|
|
||||||
- Effort: significant — needs React Testing Library setup, 3-5 days for core pages
|
|
||||||
|
|
||||||
**10. Frontend: weak error handling tests** — only 13 of 78 API functions have error scenario tests. Missing: 404 errors, network timeouts, 429 rate limiting, malformed JSON responses.
|
|
||||||
- Effort: 1-2 days
|
|
||||||
|
|
||||||
**11. Context cancellation / timeout tests** — no service or handler tests verify correct behavior when contexts are cancelled or time out. Long-running operations (network scan, EST enrollment) should gracefully handle cancellation.
|
|
||||||
- Effort: 1-2 days
|
|
||||||
|
|
||||||
**12. Concurrent operation tests** — two simultaneous revocations of the same certificate, concurrent discovery reports from multiple agents, parallel deployment jobs. Race detector catches some of this but not logic bugs.
|
|
||||||
- Effort: 1-2 days
|
|
||||||
|
|
||||||
### Docker Compose Bug Found During Audit
|
|
||||||
|
|
||||||
**`migrations/000008_verification.up.sql` is NOT mounted in `deploy/docker-compose.yml`**. The verification migration exists on disk but the Docker Compose file only mounts migrations 000001-000007. This means the demo environment is missing the `verification_status`, `verified_at`, `verification_fingerprint`, and `verification_error` columns on the jobs table.
|
|
||||||
|
|
||||||
Fix: add to docker-compose.yml:
|
|
||||||
```yaml
|
|
||||||
- ../migrations/000008_verification.up.sql:/docker-entrypoint-initdb.d/008_verification.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Manual Testing Guide
|
|
||||||
|
|
||||||
This guide covers end-to-end manual validation of all certctl features against the Docker Compose demo environment. Use this for v2.1 release validation.
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clean start (removes old data)
|
|
||||||
docker compose -f deploy/docker-compose.yml down -v
|
|
||||||
docker compose -f deploy/docker-compose.yml up -d --build
|
|
||||||
|
|
||||||
# Wait for healthy
|
|
||||||
docker compose -f deploy/docker-compose.yml ps
|
|
||||||
# All three services should show "Up (healthy)" or "Up"
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
curl -s http://localhost:8443/health | jq .
|
|
||||||
# {"status":"healthy"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1. Dashboard & Navigation
|
|
||||||
|
|
||||||
| # | Test | Steps | Expected |
|
|
||||||
|---|------|-------|----------|
|
|
||||||
| 1.1 | Dashboard loads | Open http://localhost:8443 | Stats cards show (total certs, expiring, expired, agents). 4 charts render (heatmap, trends, distribution, issuance rate) |
|
|
||||||
| 1.2 | Sidebar navigation | Click each sidebar item | All 16 nav items load without errors: Dashboard, Certificates, Agents, Fleet Overview, Jobs, Notifications, Policies, Profiles, Issuers, Targets, Owners, Teams, Agent Groups, Audit Trail, Short-Lived, Discovery, Network Scans |
|
|
||||||
| 1.3 | Auth disabled notice | Check for login prompt | No login screen (demo runs with `CERTCTL_AUTH_TYPE=none`) |
|
|
||||||
|
|
||||||
### 2. Certificate Lifecycle
|
|
||||||
|
|
||||||
| # | Test | Steps | Expected |
|
|
||||||
|---|------|-------|----------|
|
|
||||||
| 2.1 | List certificates | Certificates page | 15 demo certificates with status badges, names, expiry dates |
|
|
||||||
| 2.2 | Certificate detail | Click any certificate | Detail page shows: Certificate Details card, Lifecycle card, Lifecycle Timeline (4 steps), Policy & Profile editor, Version History, Tags |
|
|
||||||
| 2.3 | Trigger renewal | Click "Trigger Renewal" on `mc-api-prod` | Success banner. Jobs page shows new Renewal job |
|
|
||||||
| 2.4 | Trigger deployment | Click "Deploy" → select a target → "Deploy" | Success banner. Jobs page shows new Deployment job |
|
|
||||||
| 2.5 | Revoke certificate | Click "Revoke" on an active cert → select "Key Compromise" → confirm | Red revocation banner appears on cert detail. Status changes to "Revoked" |
|
|
||||||
| 2.6 | Archive certificate | Click "Archive" → confirm | Redirect to certificates list. Cert no longer shows (or shows as Archived) |
|
|
||||||
| 2.7 | Export PEM | Click "Export PEM" on cert detail | Browser downloads a .pem file. File contains valid PEM certificate |
|
|
||||||
| 2.8 | Export PKCS#12 | Click "Export PKCS#12" → enter password → download | Browser downloads a .p12 file |
|
|
||||||
| 2.9 | Deployment timeline | View cert detail for a cert with deployment jobs | Timeline shows: Requested (green) → Issued (green) → Deploying (status) → Active |
|
|
||||||
| 2.10 | Version history | View cert detail with multiple versions | Version list with "Current" badge on latest. Rollback button on previous versions |
|
|
||||||
| 2.11 | Inline policy editor | Click "Edit" on Policy & Profile card → change policy → Save | Policy updates. Card shows new values |
|
|
||||||
|
|
||||||
### 3. Bulk Operations
|
|
||||||
|
|
||||||
| # | Test | Steps | Expected |
|
|
||||||
|---|------|-------|----------|
|
|
||||||
| 3.1 | Multi-select | On Certificates page, check 3 certificates | Bulk action bar appears with count |
|
|
||||||
| 3.2 | Bulk renew | Select 3 certs → "Renew Selected" | Progress bar. 3 renewal jobs created |
|
|
||||||
| 3.3 | Bulk revoke | Select 2 certs → "Revoke Selected" → choose reason → confirm | Progress bar. Both certs revoked |
|
|
||||||
| 3.4 | Bulk reassign | Select 2 certs → "Reassign Owner" → enter new owner ID → confirm | Owner updated on both certificates |
|
|
||||||
| 3.5 | Select all | Click header checkbox | All visible certs selected |
|
|
||||||
|
|
||||||
### 4. Agent & Fleet
|
|
||||||
|
|
||||||
| # | Test | Steps | Expected |
|
|
||||||
|---|------|-------|----------|
|
|
||||||
| 4.1 | Agent list | Agents page | 5 demo agents with status (Online/Offline), OS, Architecture, IP |
|
|
||||||
| 4.2 | Agent detail | Click an agent | System Information card (OS, arch, IP, version), recent jobs, capabilities |
|
|
||||||
| 4.3 | Fleet overview | Fleet Overview page | OS distribution chart, architecture chart, version breakdown, per-platform agent listing |
|
|
||||||
| 4.4 | Agent heartbeat | Check docker-agent status | `docker-agent` shows recent heartbeat timestamp, status Online |
|
|
||||||
|
|
||||||
### 5. Jobs & Approval Workflows
|
|
||||||
|
|
||||||
| # | Test | Steps | Expected |
|
|
||||||
|---|------|-------|----------|
|
|
||||||
| 5.1 | Job list | Jobs page | Jobs with status badges. Type and status filters work |
|
|
||||||
| 5.2 | Pending approval banner | Jobs page (if AwaitingApproval jobs exist) | Amber banner: "N jobs awaiting approval" with "Show only" link |
|
|
||||||
| 5.3 | Approve renewal | Click "Approve" on an AwaitingApproval job | Job status changes to Pending or Running |
|
|
||||||
| 5.4 | Reject renewal | Click "Reject" → enter reason → confirm | Job status changes to Cancelled. Reason recorded |
|
|
||||||
| 5.5 | Cancel job | Click "Cancel" on a Pending/Running job | Job status changes to Cancelled |
|
|
||||||
| 5.6 | Status filter | Select "AwaitingApproval" from status dropdown | Only AwaitingApproval jobs shown |
|
|
||||||
| 5.7 | Type filter | Select "Deployment" from type dropdown | Only Deployment jobs shown |
|
|
||||||
|
|
||||||
### 6. Discovery & Network Scanning
|
|
||||||
|
|
||||||
| # | Test | Steps | Expected |
|
|
||||||
|---|------|-------|----------|
|
|
||||||
| 6.1 | Discovery page | Discovery nav item | Summary stats bar (Unmanaged/Managed/Dismissed counts), certificate table |
|
|
||||||
| 6.2 | Claim cert | Click "Claim" on an unmanaged cert → enter managed cert ID → confirm | Status changes to Managed |
|
|
||||||
| 6.3 | Dismiss cert | Click "Dismiss" on an unmanaged cert | Status changes to Dismissed |
|
|
||||||
| 6.4 | Discovery filters | Filter by status (Unmanaged) | Only unmanaged certs shown |
|
|
||||||
| 6.5 | Scan history | Expand scan history panel | List of past scans with timestamps, cert counts |
|
|
||||||
| 6.6 | Network scan list | Network Scans page | Demo scan targets with CIDRs, ports, intervals |
|
|
||||||
| 6.7 | Create scan target | Click "+ New Target" → fill form → create | New target appears in list |
|
|
||||||
| 6.8 | Trigger scan | Click "Scan Now" on a target | Scan triggered (may timeout in demo if targets unreachable — that's OK) |
|
|
||||||
| 6.9 | Delete scan target | Click "Delete" on a target → confirm | Target removed from list |
|
|
||||||
|
|
||||||
### 7. Target Connector Wizard
|
|
||||||
|
|
||||||
| # | Test | Steps | Expected |
|
|
||||||
|---|------|-------|----------|
|
|
||||||
| 7.1 | Open wizard | Targets page → "+ New Target" | 3-step wizard opens: Select Type → Configure → Review |
|
|
||||||
| 7.2 | NGINX type | Select NGINX → Next | Config fields: Certificate Path*, Key Path*, Chain Path, Reload Command |
|
|
||||||
| 7.3 | Apache type | Select Apache → Next | Config fields: Certificate Path*, Key Path*, Chain Path, Reload Command |
|
|
||||||
| 7.4 | HAProxy type | Select HAProxy → Next | Config fields: Combined PEM Path*, Reload Command, Validate Command |
|
|
||||||
| 7.5 | Traefik type | Select Traefik → Next | Config fields: Certificate Directory*, Certificate Filename, Key Filename |
|
|
||||||
| 7.6 | Caddy type | Select Caddy → Next | Config fields: Deployment Mode*, Admin API URL, Certificate Directory, Certificate Filename, Key Filename |
|
|
||||||
| 7.7 | F5 BIG-IP type | Select F5 BIG-IP → Next | Config fields: Management IP*, Partition, Proxy Agent ID |
|
|
||||||
| 7.8 | IIS type | Select IIS → Next | Config fields: IIS Site Name*, Binding IP, Binding Port, Certificate Store |
|
|
||||||
| 7.9 | Review & create | Fill required fields → Review → Create Target | Target appears in list with correct type and config |
|
|
||||||
| 7.10 | Validation | Leave required fields empty → try to proceed | "Next" / "Review" button disabled |
|
|
||||||
|
|
||||||
### 8. Policies, Profiles & Ownership
|
|
||||||
|
|
||||||
| # | Test | Steps | Expected |
|
|
||||||
|---|------|-------|----------|
|
|
||||||
| 8.1 | Policy list | Policies page | 5 demo policies with severity bar |
|
|
||||||
| 8.2 | Create policy | Create a new policy with name, type, severity, config | Policy appears in list |
|
|
||||||
| 8.3 | Profile list | Profiles page | Demo profiles with allowed key types, max TTL, EKUs |
|
|
||||||
| 8.4 | S/MIME profile | Check `prof-smime` profile | Shows `emailProtection` EKU, 365-day max TTL |
|
|
||||||
| 8.5 | Owner list | Owners page | Demo owners with email and team assignment |
|
|
||||||
| 8.6 | Team list | Teams page | Demo teams |
|
|
||||||
| 8.7 | Agent groups | Agent Groups page | Demo groups with dynamic criteria badges (OS, arch, CIDR, version) |
|
|
||||||
|
|
||||||
### 9. Observability
|
|
||||||
|
|
||||||
| # | Test | Steps | Expected |
|
|
||||||
|---|------|-------|----------|
|
|
||||||
| 9.1 | Audit trail | Audit Trail page | Events with actor, action, resource, timestamp. Time range filter works |
|
|
||||||
| 9.2 | Audit export CSV | Click "Export CSV" | Downloads .csv file with filtered audit events |
|
|
||||||
| 9.3 | Audit export JSON | Click "Export JSON" | Downloads .json file with filtered audit events |
|
|
||||||
| 9.4 | Short-lived creds | Short-Lived page | Filtered view of certs with TTL < 1 hour. Live countdown timers |
|
|
||||||
| 9.5 | Notifications | Notifications page | Grouped by certificate. Read/unread state. Mark as read works |
|
|
||||||
| 9.6 | JSON metrics | `curl http://localhost:8443/api/v1/metrics \| jq .` | Returns gauges (cert totals, agent counts), counters (jobs), uptime |
|
|
||||||
| 9.7 | Prometheus metrics | `curl http://localhost:8443/api/v1/metrics/prometheus` | Returns text/plain with `certctl_` prefixed metrics, `# HELP` and `# TYPE` lines |
|
|
||||||
| 9.8 | Stats summary | `curl http://localhost:8443/api/v1/stats/summary \| jq .` | Returns total_certificates, expiring, expired, agent counts, job counts |
|
|
||||||
|
|
||||||
### 10. API Endpoints (curl)
|
|
||||||
|
|
||||||
Run these against the demo environment to verify the API layer:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Health
|
|
||||||
curl -s http://localhost:8443/health | jq .
|
|
||||||
|
|
||||||
# Certificate CRUD
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod | jq '.common_name'
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?status=Active&sort=-notAfter&fields=id,common_name,status,expires_at" | jq .
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?page_size=3" | jq '.next_cursor'
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq '.total'
|
|
||||||
|
|
||||||
# Certificate deployments
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
|
|
||||||
|
|
||||||
# Renewal
|
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-api-prod/renew | jq .
|
|
||||||
|
|
||||||
# Revocation
|
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-internal-staging/revoke \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"reason": "superseded"}' | jq .
|
|
||||||
|
|
||||||
# CRL (JSON)
|
|
||||||
curl -s http://localhost:8443/api/v1/crl | jq .
|
|
||||||
|
|
||||||
# Export PEM
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/export/pem | jq .
|
|
||||||
curl -s "http://localhost:8443/api/v1/certificates/mc-api-prod/export/pem?download=true" -o cert.pem
|
|
||||||
|
|
||||||
# Export PKCS#12
|
|
||||||
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-api-prod/export/pkcs12 \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"password": "test123"}' -o cert.p12
|
|
||||||
|
|
||||||
# Agents
|
|
||||||
curl -s http://localhost:8443/api/v1/agents | jq '.total'
|
|
||||||
curl -s http://localhost:8443/api/v1/agents/ag-web-prod | jq '.os, .architecture, .ip_address'
|
|
||||||
curl -s http://localhost:8443/api/v1/agents/ag-web-prod/work | jq .
|
|
||||||
|
|
||||||
# Jobs
|
|
||||||
curl -s http://localhost:8443/api/v1/jobs | jq '.total'
|
|
||||||
curl -s "http://localhost:8443/api/v1/jobs?status=AwaitingApproval" | jq '.total'
|
|
||||||
|
|
||||||
# Approval
|
|
||||||
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID_HERE/approve | jq .
|
|
||||||
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID_HERE/reject \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"reason": "Not approved for this window"}' | jq .
|
|
||||||
|
|
||||||
# Discovery
|
|
||||||
curl -s http://localhost:8443/api/v1/discovered-certificates | jq '.total'
|
|
||||||
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
|
|
||||||
curl -s http://localhost:8443/api/v1/discovery-scans | jq '.total'
|
|
||||||
|
|
||||||
# Network scan targets
|
|
||||||
curl -s http://localhost:8443/api/v1/network-scan-targets | jq '.total'
|
|
||||||
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"name": "test-scan", "cidrs": ["192.168.1.0/24"], "ports": [443, 8443]}' | jq .
|
|
||||||
|
|
||||||
# Policies, profiles, teams, owners, agent groups
|
|
||||||
curl -s http://localhost:8443/api/v1/policies | jq '.total'
|
|
||||||
curl -s http://localhost:8443/api/v1/profiles | jq '.data[] | {id, name, allowed_ekus}'
|
|
||||||
curl -s http://localhost:8443/api/v1/teams | jq '.total'
|
|
||||||
curl -s http://localhost:8443/api/v1/owners | jq '.total'
|
|
||||||
curl -s http://localhost:8443/api/v1/agent-groups | jq '.total'
|
|
||||||
|
|
||||||
# Stats
|
|
||||||
curl -s http://localhost:8443/api/v1/stats/summary | jq .
|
|
||||||
curl -s http://localhost:8443/api/v1/stats/certificates-by-status | jq .
|
|
||||||
curl -s "http://localhost:8443/api/v1/stats/expiration-timeline?days=90" | jq .
|
|
||||||
curl -s "http://localhost:8443/api/v1/stats/job-trends?days=30" | jq .
|
|
||||||
curl -s "http://localhost:8443/api/v1/stats/issuance-rate?days=30" | jq .
|
|
||||||
|
|
||||||
# Metrics
|
|
||||||
curl -s http://localhost:8443/api/v1/metrics | jq .
|
|
||||||
curl -s http://localhost:8443/api/v1/metrics/prometheus
|
|
||||||
|
|
||||||
# Audit
|
|
||||||
curl -s http://localhost:8443/api/v1/audit | jq '.total'
|
|
||||||
curl -s "http://localhost:8443/api/v1/audit?resource_type=certificate&action=revoke" | jq .
|
|
||||||
|
|
||||||
# Notifications
|
|
||||||
curl -s http://localhost:8443/api/v1/notifications | jq '.total'
|
|
||||||
|
|
||||||
# Issuers and targets
|
|
||||||
curl -s http://localhost:8443/api/v1/issuers | jq '.data[] | {id, name, type}'
|
|
||||||
curl -s http://localhost:8443/api/v1/targets | jq '.data[] | {id, name, type, hostname}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 11. EST Server (RFC 7030)
|
|
||||||
|
|
||||||
EST requires `CERTCTL_EST_ENABLED=true` in the server environment. Add it to docker-compose and restart:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get CA certs (PKCS#7)
|
|
||||||
curl -s http://localhost:8443/.well-known/est/cacerts
|
|
||||||
|
|
||||||
# Get CSR attributes
|
|
||||||
curl -s http://localhost:8443/.well-known/est/csrattrs
|
|
||||||
|
|
||||||
# Simple enroll (requires a valid CSR in base64 DER or PEM format)
|
|
||||||
# Generate a test CSR:
|
|
||||||
openssl req -new -newkey rsa:2048 -nodes -keyout /tmp/test.key -subj "/CN=test.example.com" | \
|
|
||||||
base64 -w0 | \
|
|
||||||
curl -s -X POST http://localhost:8443/.well-known/est/simpleenroll \
|
|
||||||
-H "Content-Type: application/pkcs10" \
|
|
||||||
-d @-
|
|
||||||
```
|
|
||||||
|
|
||||||
### 12. CLI Tool
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build CLI (requires Go)
|
|
||||||
go build -o certctl-cli ./cmd/cli/
|
|
||||||
|
|
||||||
# Configure
|
|
||||||
export CERTCTL_SERVER_URL=http://localhost:8443
|
|
||||||
|
|
||||||
# Test all subcommands
|
|
||||||
./certctl-cli health
|
|
||||||
./certctl-cli metrics
|
|
||||||
./certctl-cli certs list
|
|
||||||
./certctl-cli certs list --format json
|
|
||||||
./certctl-cli certs get mc-api-prod
|
|
||||||
./certctl-cli certs renew mc-api-prod
|
|
||||||
./certctl-cli certs revoke mc-internal-staging --reason superseded
|
|
||||||
./certctl-cli agents list
|
|
||||||
./certctl-cli jobs list
|
|
||||||
|
|
||||||
# Bulk import
|
|
||||||
echo "-----BEGIN CERTIFICATE-----
|
|
||||||
... (paste a valid PEM cert) ...
|
|
||||||
-----END CERTIFICATE-----" > /tmp/test-import.pem
|
|
||||||
./certctl-cli import /tmp/test-import.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
### 13. Auth Flow (requires restart with auth enabled)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Restart with auth
|
|
||||||
docker compose -f deploy/docker-compose.yml down
|
|
||||||
CERTCTL_AUTH_TYPE=api-key CERTCTL_AUTH_SECRET=test-secret-key \
|
|
||||||
docker compose -f deploy/docker-compose.yml up -d --build
|
|
||||||
|
|
||||||
# API should reject without key
|
|
||||||
curl -s http://localhost:8443/api/v1/certificates
|
|
||||||
# 401 Unauthorized
|
|
||||||
|
|
||||||
# API works with key
|
|
||||||
curl -s -H "Authorization: Bearer test-secret-key" http://localhost:8443/api/v1/certificates | jq '.total'
|
|
||||||
|
|
||||||
# GUI should show login screen
|
|
||||||
# Open http://localhost:8443 — enter "test-secret-key" — dashboard loads
|
|
||||||
# Logout button in sidebar should clear auth and redirect to login
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pre-Release Checklist
|
|
||||||
|
|
||||||
### Automated (CI must pass)
|
|
||||||
|
|
||||||
- [ ] `go vet ./...` — no issues
|
|
||||||
- [ ] `golangci-lint run ./...` — no issues
|
|
||||||
- [ ] `govulncheck ./...` — no known vulnerabilities
|
|
||||||
- [ ] `go test -race` — no race conditions detected
|
|
||||||
- [ ] Coverage thresholds met (service 55%+, handler 60%+, domain 40%+, middleware 30%+)
|
|
||||||
- [ ] `npx tsc --noEmit` — no TypeScript errors
|
|
||||||
- [ ] `npx vitest run` — all frontend tests pass (107+)
|
|
||||||
- [ ] `npx vite build` — production build succeeds
|
|
||||||
|
|
||||||
### Manual (v2.1 release gate)
|
|
||||||
|
|
||||||
- [ ] Docker Compose starts cleanly from scratch (`down -v` then `up --build`)
|
|
||||||
- [ ] All 16 sidebar navigation items load without console errors
|
|
||||||
- [ ] Dashboard charts render with demo data
|
|
||||||
- [ ] Certificate CRUD: list, detail, renew, deploy, revoke, archive all work
|
|
||||||
- [ ] Bulk operations: multi-select, bulk renew, bulk revoke with progress bars
|
|
||||||
- [ ] Export: PEM download and PKCS#12 download both produce valid files
|
|
||||||
- [ ] Target wizard: all 7 target types show correct config fields (NGINX, Apache, HAProxy, Traefik, Caddy, F5, IIS)
|
|
||||||
- [ ] Deployment timeline shows correct step progression
|
|
||||||
- [ ] Jobs page: status/type filters, approval workflow (approve/reject with reason)
|
|
||||||
- [ ] Discovery page: summary stats, claim/dismiss, scan history
|
|
||||||
- [ ] Network scans: CRUD, trigger scan
|
|
||||||
- [ ] Audit trail: time range filter, CSV export, JSON export
|
|
||||||
- [ ] Prometheus endpoint returns valid exposition format
|
|
||||||
- [ ] CLI: `health`, `certs list`, `certs get`, `agents list` all return data
|
|
||||||
- [ ] Auth flow: login screen appears with auth enabled, API rejects without key
|
|
||||||
|
|
||||||
### Known Limitations
|
|
||||||
|
|
||||||
- EST enrollment requires `CERTCTL_EST_ENABLED=true` (off by default in demo)
|
|
||||||
- Network scans will timeout scanning demo CIDRs (no real hosts) — this is expected
|
|
||||||
- Agent keygen mode is `server` in demo (production uses `agent` for key isolation)
|
|
||||||
- OCSP/CRL endpoints require the Local CA to have been used for issuance (demo uses seeded certs, not issued via Local CA — OCSP/CRL may return empty results)
|
|
||||||
- Post-deployment TLS verification requires a real TLS endpoint to probe — not testable in basic demo setup
|
|
||||||
- Verification migration (000008) needs to be added to docker-compose.yml for full feature availability
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prioritized Test Backlog
|
|
||||||
|
|
||||||
For the engineering team to close gaps over the next 2-3 sprints:
|
|
||||||
|
|
||||||
**Sprint 1 (1 week):**
|
|
||||||
1. Fix docker-compose migration gap (000008_verification)
|
|
||||||
2. Add `service/deployment_test.go` — 15 tests for deployment orchestration
|
|
||||||
3. Add `service/target_test.go` — 8 tests for target CRUD
|
|
||||||
4. Add missing handler tests: UpdateAgentGroup, UpdateIssuer, Get/UpdateNetworkScanTarget
|
|
||||||
|
|
||||||
**Sprint 2 (1 week):**
|
|
||||||
5. Add `CompleteAgentCSRRenewal` service tests — 8 tests
|
|
||||||
6. Add `ExpireShortLivedCertificates` service tests — 4 tests
|
|
||||||
7. Add domain model tests for `job.go`, `certificate.go`, `agent_group.go` — 20 tests
|
|
||||||
8. Frontend: add error scenario tests for API client (404, 429, timeout) — 15 tests
|
|
||||||
|
|
||||||
**Sprint 3 (1-2 weeks):**
|
|
||||||
9. Expand scheduler tests — test loop execution with mocked time
|
|
||||||
10. Add agent binary tests — mock HTTP control plane, test heartbeat + CSR + deploy flows
|
|
||||||
11. Frontend: add React component tests for LoginPage, CertificateDetailPage, TargetsPage wizard
|
|
||||||
12. Context cancellation tests for long-running service operations
|
|
||||||
+1
-1
@@ -71,7 +71,7 @@ docker compose up -d
|
|||||||
open http://localhost:8443
|
open http://localhost:8443
|
||||||
```
|
```
|
||||||
|
|
||||||
The demo seeds 15 certificates, 5 agents, 5 deployment targets, discovery data, network scan targets, and pending approval jobs so you can explore every feature immediately.
|
The demo seeds 35 certificates across 5 issuers, 8 agents, 8 deployment targets, 90 days of job history, discovery scan data, network scan targets, and pending approval jobs so you can explore every feature immediately.
|
||||||
|
|
||||||
See the [Quickstart Guide](quickstart.md) for a full walkthrough.
|
See the [Quickstart Guide](quickstart.md) for a full walkthrough.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# certctl + NGINX + Let's Encrypt
|
||||||
|
|
||||||
|
This example demonstrates certctl's core use case: **automatically manage TLS certificates for NGINX using Let's Encrypt (ACME HTTP-01 challenges).**
|
||||||
|
|
||||||
|
## What This Does
|
||||||
|
|
||||||
|
- Deploys certctl server (control plane) with PostgreSQL
|
||||||
|
- Deploys certctl agent on the same network (in production: on your NGINX server)
|
||||||
|
- Configures Let's Encrypt as the certificate issuer via ACME v2
|
||||||
|
- Demonstrates HTTP-01 challenge solving (requires port 80 open to the internet)
|
||||||
|
- Shows how to set up 3 example domains for certificate enrollment and renewal
|
||||||
|
- Automatically renews certificates 30 days before expiration
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Your Domain (example.com)
|
||||||
|
↓ [HTTP-01 validation, port 80]
|
||||||
|
Let's Encrypt ACME
|
||||||
|
↓ [CSR submission]
|
||||||
|
certctl Server (control plane)
|
||||||
|
↓ [API polling]
|
||||||
|
certctl Agent (on NGINX server)
|
||||||
|
↓ [deploy cert+key]
|
||||||
|
NGINX Reverse Proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Docker & Docker Compose** (v20.10+)
|
||||||
|
2. **A domain name** pointing to your server (e.g., `example.com`)
|
||||||
|
3. **Ports 80 and 443 open** to the internet (ACME HTTP-01 needs port 80)
|
||||||
|
4. **Valid email address** for Let's Encrypt account (errors and renewal notices)
|
||||||
|
|
||||||
|
If you don't have a real domain or can't open port 80, see [Customization Tips](#customization-tips) below.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Clone or copy this example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/acme-nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create a `.env` file with your settings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > .env <<'EOF'
|
||||||
|
# Your email for Let's Encrypt account
|
||||||
|
ACME_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
# Database password (change this in production!)
|
||||||
|
DB_PASSWORD=certctl-demo-password
|
||||||
|
|
||||||
|
# Agent API key (generate a real one in production)
|
||||||
|
AGENT_API_KEY=agent-demo-key
|
||||||
|
|
||||||
|
# Server port (certctl listens here internally on 8443; expose as needed)
|
||||||
|
SERVER_PORT=8443
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. (Optional) Create an NGINX config
|
||||||
|
|
||||||
|
If you have a real domain and want NGINX to route traffic:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > nginx.conf <<'EOF'
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
# HTTP block for ACME challenges
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name example.com www.example.com api.example.com;
|
||||||
|
|
||||||
|
# ACME challenge directory (certctl writes validation files here)
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect HTTP to HTTPS
|
||||||
|
location / {
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS block (certificates deployed here by certctl agent)
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name example.com www.example.com api.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/example.com.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://upstream-service;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Or just accept the default empty NGINX config for demonstration.
|
||||||
|
|
||||||
|
### 4. Start the stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Monitor logs:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f certctl-server certctl-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Access the dashboard
|
||||||
|
|
||||||
|
Navigate to `http://localhost:8443` (or your `SERVER_PORT`)
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
- An empty certificate inventory (no certs issued yet)
|
||||||
|
- One ACME issuer ("iss-acme") configured and ready
|
||||||
|
- One agent ("nginx-agent-01") online and heartbeating
|
||||||
|
|
||||||
|
### 6. Create a certificate profile
|
||||||
|
|
||||||
|
In the certctl dashboard:
|
||||||
|
1. Go to **Profiles** (sidebar)
|
||||||
|
2. Click **New Profile**
|
||||||
|
3. Set:
|
||||||
|
- Name: `acme-prod`
|
||||||
|
- Key Type: `RSA-2048` (or `ECDSA-P256`)
|
||||||
|
- Max TTL: `90 days`
|
||||||
|
- Allowed Key Types: `RSA-2048, ECDSA-P256`
|
||||||
|
4. Save
|
||||||
|
|
||||||
|
### 7. Request a certificate
|
||||||
|
|
||||||
|
In the certctl dashboard:
|
||||||
|
1. Go to **Certificates** (sidebar)
|
||||||
|
2. Click **Request New Certificate**
|
||||||
|
3. Set:
|
||||||
|
- Common Name: `example.com`
|
||||||
|
- SANs: `www.example.com`, `api.example.com` (optional)
|
||||||
|
- Issuer: `iss-acme` (Let's Encrypt)
|
||||||
|
- Profile: `acme-prod`
|
||||||
|
4. Click **Request**
|
||||||
|
|
||||||
|
Behind the scenes:
|
||||||
|
- Server creates an `Issuance` job
|
||||||
|
- Agent polls for work, fetches the job
|
||||||
|
- Agent generates a P-256 key (never sent to server)
|
||||||
|
- Agent submits CSR to server
|
||||||
|
- Server sends CSR to Let's Encrypt ACME
|
||||||
|
- Let's Encrypt provides HTTP-01 challenge token
|
||||||
|
- Server downloads ACME challenge, returns to agent
|
||||||
|
- Agent deploys challenge file to NGINX `/.well-known/acme-challenge/`
|
||||||
|
- Let's Encrypt validates (HTTP GET to `http://example.com/.well-known/acme-challenge/...`)
|
||||||
|
- Let's Encrypt issues certificate
|
||||||
|
- Server receives certificate, passes to agent
|
||||||
|
- Agent deploys cert+key to `/etc/nginx/ssl/example.com.crt` + `.key`
|
||||||
|
- Agent reloads NGINX (`nginx -s reload`)
|
||||||
|
- Certificate is now active
|
||||||
|
|
||||||
|
### 8. View the certificate
|
||||||
|
|
||||||
|
In the dashboard:
|
||||||
|
1. Go to **Certificates**
|
||||||
|
2. Click the certificate to see:
|
||||||
|
- Common name, SANs, serial number
|
||||||
|
- Issuer (Let's Encrypt), not-before/after dates
|
||||||
|
- Status (Active, Expiring in N days, Expired)
|
||||||
|
- Deployment history (timestamps, agent name, target)
|
||||||
|
- Next auto-renewal date (30 days before expiration)
|
||||||
|
|
||||||
|
### 9. Set up automatic renewal
|
||||||
|
|
||||||
|
The server automatically checks for certificates expiring within 30 days and triggers renewal. You can:
|
||||||
|
- Adjust the threshold in the certificate's policy
|
||||||
|
- Manually trigger renewal via dashboard button
|
||||||
|
- View renewal job status and history
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Certificate Lifecycle
|
||||||
|
|
||||||
|
1. **Request** — Operator creates certificate request via dashboard or API
|
||||||
|
2. **CSR Generation** — Agent generates private key locally, submits CSR to server
|
||||||
|
3. **ACME Challenge** — Server communicates with Let's Encrypt ACME, obtains challenge
|
||||||
|
4. **Challenge Proof** — Agent deploys challenge proof to NGINX
|
||||||
|
5. **Issuance** — Let's Encrypt validates, issues certificate
|
||||||
|
6. **Deployment** — Agent receives certificate, deploys to NGINX SSL directory
|
||||||
|
7. **Reload** — Agent signals NGINX to reload (`nginx -s reload`)
|
||||||
|
8. **Verification** — Agent optionally verifies the live TLS endpoint (handshake fingerprint)
|
||||||
|
9. **Renewal** — 30 days before expiration, process repeats automatically
|
||||||
|
|
||||||
|
### HTTP-01 Challenge
|
||||||
|
|
||||||
|
ACME HTTP-01 works like this:
|
||||||
|
1. Let's Encrypt generates random token (e.g., `abc123def456`)
|
||||||
|
2. Server returns token to agent
|
||||||
|
3. Agent writes file: `/.well-known/acme-challenge/abc123def456` with value (random key material)
|
||||||
|
4. Let's Encrypt performs HTTP GET to `http://example.com/.well-known/acme-challenge/abc123def456`
|
||||||
|
5. If content matches, domain ownership is proven
|
||||||
|
6. Certificate is issued
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Port 80 must be open to the internet
|
||||||
|
- DNS must resolve your domain to your server
|
||||||
|
- NGINX must serve `/.well-known/acme-challenge/` (or certctl mounts a separate directory)
|
||||||
|
|
||||||
|
### Agent Key Generation
|
||||||
|
|
||||||
|
Keys are generated **on the agent**, never on the server:
|
||||||
|
1. Agent creates ECDSA P-256 keypair using `crypto/ecdsa`
|
||||||
|
2. Private key is stored locally on agent at `/var/lib/certctl/keys/` (readable only by certctl process)
|
||||||
|
3. Agent creates CSR (certificate signing request) with private key
|
||||||
|
4. Agent submits CSR to server
|
||||||
|
5. Server never sees the private key
|
||||||
|
6. Certificate is returned, agent stores it alongside key
|
||||||
|
7. Both key and cert used for NGINX deployment
|
||||||
|
|
||||||
|
This keeps private keys in the infrastructure where they're used, following zero-trust principles.
|
||||||
|
|
||||||
|
## Adding More Domains
|
||||||
|
|
||||||
|
### Option 1: Additional SANs on Same Certificate
|
||||||
|
|
||||||
|
Edit the existing certificate in the dashboard:
|
||||||
|
1. Click the certificate
|
||||||
|
2. Edit SANs to add `mail.example.com`, `ftp.example.com`, etc.
|
||||||
|
3. Trigger renewal
|
||||||
|
4. Agent generates new CSR with all SANs
|
||||||
|
5. Let's Encrypt validates each SAN (HTTP-01 for each)
|
||||||
|
6. Single certificate with multiple SANs is issued
|
||||||
|
|
||||||
|
### Option 2: Separate Certificates per Domain
|
||||||
|
|
||||||
|
If you want separate certificates (different issuance schedules, different targets):
|
||||||
|
1. Dashboard → **Certificates** → **Request New Certificate**
|
||||||
|
2. Common Name: `subdomain.example.com`
|
||||||
|
3. Set same issuer and profile
|
||||||
|
4. Request
|
||||||
|
|
||||||
|
Each domain gets its own cert, key, and renewal schedule.
|
||||||
|
|
||||||
|
### Wildcard Certificates (Not HTTP-01)
|
||||||
|
|
||||||
|
HTTP-01 does **not** support wildcard (`*.example.com`). To issue wildcards, use DNS-01 challenge (see [acme-wildcard-dns01](../acme-wildcard-dns01/) example).
|
||||||
|
|
||||||
|
## Customization Tips
|
||||||
|
|
||||||
|
### Using Let's Encrypt Staging (for testing)
|
||||||
|
|
||||||
|
Staging has higher rate limits and doesn't require real domains:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In .env or docker-compose.yml override:
|
||||||
|
CERTCTL_ACME_DIRECTORY_URL=https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
```
|
||||||
|
|
||||||
|
Staging certificates won't be trusted by browsers (fake CA), but you can test the full flow without hitting production rate limits.
|
||||||
|
|
||||||
|
### Disabling Port 80 Requirement (Demo Mode)
|
||||||
|
|
||||||
|
If you can't open port 80, use ACME DNS-01 instead (requires DNS provider integration). See [acme-wildcard-dns01](../acme-wildcard-dns01/) example.
|
||||||
|
|
||||||
|
Or use Local CA for internal testing:
|
||||||
|
```bash
|
||||||
|
# Switch issuer to Local CA (not public-trusted, but no challenge needed)
|
||||||
|
CERTCTL_ACME_DIRECTORY_URL= # Leave empty to disable ACME
|
||||||
|
# (then configure Local CA instead)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom NGINX Config
|
||||||
|
|
||||||
|
Replace `nginx.conf` with your own before `docker compose up`. The agent doesn't manage the NGINX config — it only deploys certificates. You're responsible for:
|
||||||
|
- Configuring SSL paths (`ssl_certificate`, `ssl_certificate_key`)
|
||||||
|
- Setting up challenge directory (`/.well-known/acme-challenge/`)
|
||||||
|
- Pointing NGINX to agent-deployed certificates
|
||||||
|
|
||||||
|
### Database Persistence
|
||||||
|
|
||||||
|
PostgreSQL data is stored in the `postgres_data` volume. To reset:
|
||||||
|
```bash
|
||||||
|
docker compose down -v # Destroy all volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing Agent Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f certctl-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- `Heartbeat successful` — agent is communicating with server
|
||||||
|
- `CSR submitted` — key generation and CSR submission worked
|
||||||
|
- `Deployment succeeded` — certificate deployed to NGINX
|
||||||
|
- `NGINX reload` — signal sent to reload
|
||||||
|
|
||||||
|
### Testing ACME Without Real Domain
|
||||||
|
|
||||||
|
Use `nip.io` (free DNS service):
|
||||||
|
1. Deploy to a server with a public IP
|
||||||
|
2. Use domain: `<your-ip>.nip.io` (e.g., `203.0.113.45.nip.io`)
|
||||||
|
3. Let's Encrypt will validate to that IP
|
||||||
|
4. Change ACME_EMAIL to a real email you control
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
Before running in production:
|
||||||
|
|
||||||
|
- [ ] Change `DB_PASSWORD` to a strong random password
|
||||||
|
- [ ] Generate a real API key for the agent (don't use the demo key)
|
||||||
|
- [ ] Enable `CERTCTL_AUTH_TYPE=api-key` and enforce authentication
|
||||||
|
- [ ] Use Let's Encrypt production directory (not staging)
|
||||||
|
- [ ] Configure `CERTCTL_CORS_ORIGINS` to restrict cross-origin access
|
||||||
|
- [ ] Use `CERTCTL_KEYGEN_MODE=agent` (default, but verify)
|
||||||
|
- [ ] Set `CERTCTL_LOG_LEVEL=warn` to reduce log noise
|
||||||
|
- [ ] Configure email notifications for certificate expiration alerts
|
||||||
|
- [ ] Set up log aggregation (Datadog, ELK, Splunk, etc.)
|
||||||
|
- [ ] Use docker secrets or external secret manager for credentials (not .env)
|
||||||
|
- [ ] Run agent on actual NGINX servers (not co-located with server for HA)
|
||||||
|
- [ ] Set up monitoring and alerting on agent heartbeat and job completion
|
||||||
|
- [ ] Implement backup/restore for PostgreSQL
|
||||||
|
- [ ] Use TLS for certctl server (terminate at reverse proxy or load balancer)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Agent heartbeat failing
|
||||||
|
```bash
|
||||||
|
docker compose logs certctl-agent
|
||||||
|
# Check: CERTCTL_SERVER_URL, CERTCTL_API_KEY, network connectivity
|
||||||
|
```
|
||||||
|
|
||||||
|
### ACME challenge failing
|
||||||
|
```bash
|
||||||
|
# Ensure port 80 is open: curl http://example.com/.well-known/acme-challenge/test
|
||||||
|
# Check NGINX is running and serving /.well-known/acme-challenge/
|
||||||
|
# Verify DNS resolves domain to your server: dig example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### NGINX reload failing
|
||||||
|
Check agent permissions on NGINX socket and that NGINX is reachable from agent container.
|
||||||
|
|
||||||
|
### Let's Encrypt rate limited
|
||||||
|
Let's Encrypt has rate limits (50 certs per domain per week). Use staging to test, or wait a week.
|
||||||
|
|
||||||
|
### Certificate not deployed to NGINX
|
||||||
|
Check agent logs for deployment errors. Verify `/etc/nginx/ssl` volume is writable by agent container.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- **Wildcard certificates**: See [acme-wildcard-dns01](../acme-wildcard-dns01/) example
|
||||||
|
- **Multiple issuers**: See [multi-issuer](../multi-issuer/) example
|
||||||
|
- **Private CA**: See [private-ca-traefik](../private-ca-traefik/) example
|
||||||
|
- **Dashboard deep dive**: Read [docs/quickstart.md](../../docs/quickstart.md)
|
||||||
|
- **REST API**: Explore [api/openapi.yaml](../../api/openapi.yaml)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
- Check [docs/troubleshooting.md](../../docs/troubleshooting.md)
|
||||||
|
- Open an issue on GitHub
|
||||||
|
- Review server and agent logs: `docker compose logs -f`
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL database for certctl
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: certctl-postgres-acme-nginx
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: certctl
|
||||||
|
POSTGRES_USER: certctl
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-certctl-dev-password}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U certctl -d certctl']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# certctl server (control plane)
|
||||||
|
certctl-server:
|
||||||
|
image: ghcr.io/shankar0123/certctl-server:latest
|
||||||
|
container_name: certctl-server-acme-nginx
|
||||||
|
environment:
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
CERTCTL_SERVER_PORT: 8443
|
||||||
|
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||||
|
|
||||||
|
# Auth (disabled for demo; production should use API keys)
|
||||||
|
CERTCTL_AUTH_TYPE: none
|
||||||
|
|
||||||
|
# CORS (allow agent communication)
|
||||||
|
CERTCTL_CORS_ORIGINS: '*'
|
||||||
|
|
||||||
|
# Key generation mode (agent-side in production, server-side for demo)
|
||||||
|
CERTCTL_KEYGEN_MODE: agent
|
||||||
|
|
||||||
|
# ACME issuer configuration
|
||||||
|
# This registers the Let's Encrypt ACME issuer
|
||||||
|
CERTCTL_ACME_DIRECTORY_URL: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
CERTCTL_ACME_EMAIL: ${ACME_EMAIL:-admin@example.com}
|
||||||
|
CERTCTL_ACME_CHALLENGE_TYPE: http-01
|
||||||
|
|
||||||
|
# Local CA as fallback for internal services (optional)
|
||||||
|
CERTCTL_CA_CERT_PATH: /etc/certctl/ca.crt
|
||||||
|
CERTCTL_CA_KEY_PATH: /etc/certctl/ca.key
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
CERTCTL_LOG_LEVEL: info
|
||||||
|
ports:
|
||||||
|
- '${SERVER_PORT:-8443}:8443'
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# certctl agent (runs on the target machine with NGINX)
|
||||||
|
# In this example, the agent is in the same compose file for simplicity.
|
||||||
|
# In production, the agent runs on each server that needs certificates.
|
||||||
|
certctl-agent:
|
||||||
|
image: ghcr.io/shankar0123/certctl-agent:latest
|
||||||
|
container_name: certctl-agent-acme-nginx
|
||||||
|
environment:
|
||||||
|
# Control plane connection
|
||||||
|
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||||
|
CERTCTL_API_KEY: ${AGENT_API_KEY:-agent-demo-key}
|
||||||
|
|
||||||
|
# Key generation (agent-side keys, never sent to server)
|
||||||
|
CERTCTL_KEYGEN_MODE: agent
|
||||||
|
CERTCTL_KEY_DIR: /var/lib/certctl/keys
|
||||||
|
|
||||||
|
# Discovery (scan existing certs so operator knows what's already deployed)
|
||||||
|
CERTCTL_DISCOVERY_DIRS: /etc/nginx/ssl
|
||||||
|
|
||||||
|
# Heartbeat interval
|
||||||
|
CERTCTL_HEARTBEAT_INTERVAL: 30s
|
||||||
|
|
||||||
|
# Agent metadata (self-reported)
|
||||||
|
CERTCTL_AGENT_NAME: nginx-agent-01
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
CERTCTL_LOG_LEVEL: info
|
||||||
|
volumes:
|
||||||
|
# Mount NGINX config and cert directories
|
||||||
|
# In production, these would be the actual NGINX paths
|
||||||
|
- nginx_certs:/etc/nginx/ssl
|
||||||
|
- nginx_conf:/etc/nginx/conf.d
|
||||||
|
# Agent key storage (persisted across restarts)
|
||||||
|
- agent_keys:/var/lib/certctl/keys
|
||||||
|
depends_on:
|
||||||
|
certctl-server:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# NGINX reverse proxy / web server
|
||||||
|
# This is where certificates will be deployed
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: certctl-nginx-acme-nginx
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
- '443:443'
|
||||||
|
volumes:
|
||||||
|
- nginx_conf:/etc/nginx/conf.d
|
||||||
|
- nginx_certs:/etc/nginx/ssl
|
||||||
|
# Default NGINX config (if not provided by agent)
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- certctl-agent
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost/ || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
certctl-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
nginx_certs:
|
||||||
|
driver: local
|
||||||
|
nginx_conf:
|
||||||
|
driver: local
|
||||||
|
agent_keys:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# ACME Wildcard DNS-01 Example
|
||||||
|
|
||||||
|
**What this does:** Issues wildcard certificates (e.g., `*.example.com`) from Let's Encrypt using DNS-01 challenge validation.
|
||||||
|
|
||||||
|
This example is ideal for:
|
||||||
|
- Issuing wildcard certificates (`*.example.com`)
|
||||||
|
- Services behind NAT, firewalls, or non-public networks
|
||||||
|
- Batch issuance of multiple domains in parallel
|
||||||
|
- Internal PKI with public DNS names
|
||||||
|
- Scenarios where you have programmatic access to your DNS provider's API
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before running this example, you need:
|
||||||
|
|
||||||
|
1. **A domain name** (e.g., `example.com`) that you control and can manage DNS records for
|
||||||
|
2. **DNS provider credentials:**
|
||||||
|
- **Cloudflare** (example included): API token with DNS:write permission + Zone ID
|
||||||
|
- **Route53 (AWS)**: AWS access key + secret key
|
||||||
|
- **Azure DNS**: Azure subscription ID + credentials
|
||||||
|
- **Other providers**: See "Adapting for Other DNS Providers" below
|
||||||
|
3. **Docker and Docker Compose** installed
|
||||||
|
|
||||||
|
## Quick Start (Cloudflare)
|
||||||
|
|
||||||
|
### Step 1: Get Cloudflare Credentials
|
||||||
|
|
||||||
|
1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com)
|
||||||
|
2. Select your domain (e.g., `example.com`)
|
||||||
|
3. In the sidebar, find **Zone ID** (copy this)
|
||||||
|
4. Go to **Account Settings > API Tokens**
|
||||||
|
5. Create a new token with these scopes:
|
||||||
|
- **Zone > Zone:Read** (to list DNS records)
|
||||||
|
- **Zone > DNS:Write** (to create/delete challenge records)
|
||||||
|
6. Copy the API token
|
||||||
|
|
||||||
|
### Step 2: Set Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file in this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
CLOUDFLARE_API_TOKEN=your-api-token-here
|
||||||
|
CLOUDFLARE_ZONE_ID=your-zone-id-here
|
||||||
|
ACME_EMAIL=admin@example.com
|
||||||
|
DB_PASSWORD=your-secure-db-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Or export them in your shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CLOUDFLARE_API_TOKEN="your-api-token-here"
|
||||||
|
export CLOUDFLARE_ZONE_ID="your-zone-id-here"
|
||||||
|
export ACME_EMAIL="admin@example.com"
|
||||||
|
export DB_PASSWORD="your-secure-db-password"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Make DNS Scripts Executable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x dns-hooks/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Start the Stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts:
|
||||||
|
- **certctl-server** (port 8443): Control plane and ACME orchestrator
|
||||||
|
- **postgres**: Certificate metadata database
|
||||||
|
- **certctl-agent**: Certificate deployment agent
|
||||||
|
|
||||||
|
### Step 5: Access the Dashboard
|
||||||
|
|
||||||
|
Open your browser to `http://localhost:8443`
|
||||||
|
|
||||||
|
### Step 6: Create a Wildcard Certificate
|
||||||
|
|
||||||
|
1. Go to **Issuers** page
|
||||||
|
2. Verify the ACME issuer is registered
|
||||||
|
3. Go to **Certificates** > **New Certificate**
|
||||||
|
4. Fill in:
|
||||||
|
- **Issuer:** ACME (Let's Encrypt)
|
||||||
|
- **Common Name:** `*.example.com`
|
||||||
|
- **Subject Alt Names:** `example.com` (to also cover the root domain)
|
||||||
|
5. Click **Request**
|
||||||
|
|
||||||
|
The renewal job will:
|
||||||
|
1. Send a request to Let's Encrypt
|
||||||
|
2. Run `dns-hooks/cloudflare-present.sh` to create `_acme-challenge.example.com` TXT record
|
||||||
|
3. Wait for Let's Encrypt to verify the TXT record
|
||||||
|
4. Issue the certificate
|
||||||
|
5. Run `dns-hooks/cloudflare-cleanup.sh` to delete the temporary TXT record
|
||||||
|
|
||||||
|
### Step 7: Monitor the Job
|
||||||
|
|
||||||
|
Go to **Jobs** page to see the renewal progress:
|
||||||
|
- **AwaitingCSR**: Agent is generating the CSR
|
||||||
|
- **Running**: ACME challenge in progress (DNS record being validated)
|
||||||
|
- **Completed**: Certificate issued and stored
|
||||||
|
- **Failed**: Check logs for errors (e.g., DNS provider API issues)
|
||||||
|
|
||||||
|
## How DNS-01 Works
|
||||||
|
|
||||||
|
The DNS-01 challenge proves you own a domain by creating a DNS TXT record:
|
||||||
|
|
||||||
|
```
|
||||||
|
_acme-challenge.example.com TXT "acme-validation-token-xxxxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
Let's Encrypt then queries this TXT record. Once verified, it issues the certificate and certctl cleans up the TXT record.
|
||||||
|
|
||||||
|
**Why DNS-01 is better than HTTP-01 for wildcards:**
|
||||||
|
- HTTP-01 requires a public web server; DNS-01 works anywhere
|
||||||
|
- Wildcard certificates require DNS proof (not HTTP)
|
||||||
|
- DNS challenges can be solved for multiple domains in parallel
|
||||||
|
- No need for public IP or inbound port 80/443
|
||||||
|
|
||||||
|
## Adapting for Other DNS Providers
|
||||||
|
|
||||||
|
The example uses Cloudflare, but certctl supports **any DNS provider via pluggable shell scripts**.
|
||||||
|
|
||||||
|
### AWS Route53
|
||||||
|
|
||||||
|
Replace the `CERTCTL_ACME_DNS_PRESENT_SCRIPT` and `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` in `docker-compose.yml` with:
|
||||||
|
- `./dns-hooks/route53-present.sh`
|
||||||
|
- `./dns-hooks/route53-cleanup.sh`
|
||||||
|
|
||||||
|
Example script outline (using AWS CLI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
DOMAIN="$1"
|
||||||
|
VALIDATION_TOKEN="$2"
|
||||||
|
|
||||||
|
# Get Route53 hosted zone ID for the domain
|
||||||
|
ZONE_ID=$(aws route53 list-hosted-zones --query \
|
||||||
|
"HostedZones[?Name=='$DOMAIN.'].Id" --output text | cut -d'/' -f3)
|
||||||
|
|
||||||
|
# Create TXT record
|
||||||
|
aws route53 change-resource-record-sets \
|
||||||
|
--hosted-zone-id "$ZONE_ID" \
|
||||||
|
--change-batch "{
|
||||||
|
\"Changes\": [{
|
||||||
|
\"Action\": \"CREATE\",
|
||||||
|
\"ResourceRecordSet\": {
|
||||||
|
\"Name\": \"_acme-challenge.$DOMAIN\",
|
||||||
|
\"Type\": \"TXT\",
|
||||||
|
\"TTL\": 120,
|
||||||
|
\"ResourceRecords\": [{\"Value\": \"\\\"$VALIDATION_TOKEN\\\"\"}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Azure DNS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
DOMAIN="$1"
|
||||||
|
VALIDATION_TOKEN="$2"
|
||||||
|
|
||||||
|
# Set Azure credentials via environment variables
|
||||||
|
# AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, AZURE_TENANT_ID, etc.
|
||||||
|
|
||||||
|
az network dns record-set txt create \
|
||||||
|
--resource-group "$AZURE_RESOURCE_GROUP" \
|
||||||
|
--zone-name "$DOMAIN" \
|
||||||
|
--name "_acme-challenge" \
|
||||||
|
--ttl 120 \
|
||||||
|
--txt-value "$VALIDATION_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generic DNS Provider (using dig + TSIG)
|
||||||
|
|
||||||
|
If your DNS provider supports NSUPDATE (RFC 2136):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
DOMAIN="$1"
|
||||||
|
VALIDATION_TOKEN="$2"
|
||||||
|
|
||||||
|
nsupdate <<EOF
|
||||||
|
zone $DOMAIN
|
||||||
|
update add _acme-challenge.$DOMAIN 120 TXT "$VALIDATION_TOKEN"
|
||||||
|
send
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual DNS (for testing)
|
||||||
|
|
||||||
|
Replace scripts with no-ops during testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
echo "Please create: _acme-challenge.$1 TXT $2"
|
||||||
|
sleep 60 # Manual wait for you to create the record
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative: DNS-PERSIST-01 (Standing Records)
|
||||||
|
|
||||||
|
If your DNS provider supports it, use **DNS-PERSIST-01** for zero-maintenance renewals.
|
||||||
|
|
||||||
|
Instead of creating a new TXT record for each renewal, you create one standing record once:
|
||||||
|
|
||||||
|
```
|
||||||
|
_validation-persist.example.com TXT "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/12345678"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then every renewal uses the same record — no cleanup scripts needed!
|
||||||
|
|
||||||
|
To enable in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
CERTCTL_ACME_CHALLENGE_TYPE: dns-persist-01
|
||||||
|
CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN: letsencrypt.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Certctl will:
|
||||||
|
1. Fetch your ACME account URI
|
||||||
|
2. Create the standing `_validation-persist` record once
|
||||||
|
3. Reuse it for all future renewals (no per-renewal DNS updates)
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
1. **API Token Scope:** Restrict Cloudflare/AWS tokens to DNS:write only (not full account access)
|
||||||
|
2. **Key Generation:** This example uses agent-side key generation (`CERTCTL_KEYGEN_MODE=agent`), which is production-standard. Private keys never leave the agent.
|
||||||
|
3. **Script Safety:** The DNS scripts run in the certctl-server container. For production:
|
||||||
|
- Validate script inputs (already done in certctl code)
|
||||||
|
- Log all API calls
|
||||||
|
- Monitor for failed DNS operations
|
||||||
|
- Use a separate proxy agent for DNS operations if needed
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### DNS record not created
|
||||||
|
|
||||||
|
Check the server logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs certctl-server-dns01
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for lines like:
|
||||||
|
- `[certctl DNS-01] Creating DNS record: _acme-challenge.example.com`
|
||||||
|
- `Error: Cloudflare API failed: ...`
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
- Missing or invalid `CLOUDFLARE_API_TOKEN`
|
||||||
|
- Invalid `CLOUDFLARE_ZONE_ID`
|
||||||
|
- API token doesn't have DNS:write permission
|
||||||
|
- Domain not in your Cloudflare account
|
||||||
|
|
||||||
|
### DNS propagation timeout
|
||||||
|
|
||||||
|
If the TLS negotiation fails, it might be DNS caching. Increase the wait time in the script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sleep 30 # Increase from 10 to 30 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Let's Encrypt rate limits
|
||||||
|
|
||||||
|
Let's Encrypt has strict rate limits:
|
||||||
|
- 50 certificates per registered domain per week
|
||||||
|
- 5 duplicate certificates per domain per week
|
||||||
|
|
||||||
|
For testing, use the **staging directory**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
CERTCTL_ACME_DIRECTORY_URL: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
```
|
||||||
|
|
||||||
|
(Staging certs won't be trusted by browsers, but don't count against rate limits.)
|
||||||
|
|
||||||
|
### Job fails with "CSR generation timeout"
|
||||||
|
|
||||||
|
If your DNS provider is very slow, increase the timeout in the cleanup script or add a longer wait time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sleep 60 # Wait 1 minute for DNS propagation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Monitor renewals:** Set up notifications (email, Slack, PagerDuty) for renewal events
|
||||||
|
2. **Deploy certificates:** Configure target connectors (NGINX, HAProxy, Traefik) to automatically deploy issued certs
|
||||||
|
3. **Multi-domain:** Use certificate profiles to group wildcard + subdomain certs
|
||||||
|
4. **Backup DNS scripts:** Version control your DNS provider scripts in git
|
||||||
|
|
||||||
|
## Files in This Example
|
||||||
|
|
||||||
|
- **docker-compose.yml** — Container stack definition with ACME DNS-01 configuration
|
||||||
|
- **dns-hooks/cloudflare-present.sh** — Creates `_acme-challenge` TXT record (Cloudflare)
|
||||||
|
- **dns-hooks/cloudflare-cleanup.sh** — Deletes `_acme-challenge` TXT record (Cloudflare)
|
||||||
|
- **README.md** — This file
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [certctl Documentation](../../docs/)
|
||||||
|
- [ACME Specification (RFC 8555)](https://tools.ietf.org/html/rfc8555)
|
||||||
|
- [DNS-01 Challenge Details](https://letsencrypt.org/docs/challenge-types/#dns-01)
|
||||||
|
- [DNS-PERSIST-01 (IETF Draft)](https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-persist)
|
||||||
|
- [Let's Encrypt Documentation](https://letsencrypt.org/docs/)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
#
|
||||||
|
# Cloudflare DNS-01 Challenge Script (CLEANUP)
|
||||||
|
#
|
||||||
|
# This script removes a DNS TXT record after ACME DNS-01 challenge validation.
|
||||||
|
# Called by certctl after certificate issuance to clean up temporary challenge records.
|
||||||
|
#
|
||||||
|
# certctl sets these environment variables before invoking this script:
|
||||||
|
# CERTCTL_DNS_DOMAIN - Base domain (e.g., "example.com")
|
||||||
|
# CERTCTL_DNS_FQDN - Full challenge FQDN (e.g., "_acme-challenge.example.com")
|
||||||
|
# CERTCTL_DNS_VALUE - Challenge value/token that was in the TXT record
|
||||||
|
#
|
||||||
|
# You must set these environment variables before running:
|
||||||
|
# CLOUDFLARE_API_TOKEN - Cloudflare API token with DNS:write permission
|
||||||
|
# CLOUDFLARE_ZONE_ID - Cloudflare zone ID for your domain
|
||||||
|
#
|
||||||
|
# Error Handling:
|
||||||
|
# This script exits 0 on success, non-zero on failure.
|
||||||
|
# If cleanup fails, certctl logs the error but doesn't block renewals.
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Get values from certctl environment variables
|
||||||
|
DOMAIN="${CERTCTL_DNS_DOMAIN:-}"
|
||||||
|
RECORD_NAME="${CERTCTL_DNS_FQDN:-}"
|
||||||
|
VALIDATION_TOKEN="${CERTCTL_DNS_VALUE:-}"
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
if [[ -z "$DOMAIN" || -z "$RECORD_NAME" || -z "$VALIDATION_TOKEN" ]]; then
|
||||||
|
echo "Error: Required certctl environment variables not set (CERTCTL_DNS_DOMAIN, CERTCTL_DNS_FQDN, CERTCTL_DNS_VALUE)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate environment
|
||||||
|
if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then
|
||||||
|
echo "Error: CLOUDFLARE_API_TOKEN environment variable not set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${CLOUDFLARE_ZONE_ID:-}" ]]; then
|
||||||
|
echo "Error: CLOUDFLARE_ZONE_ID environment variable not set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate RECORD_NAME (set by certctl above)
|
||||||
|
RECORD_TYPE="TXT"
|
||||||
|
|
||||||
|
# Cloudflare API endpoint
|
||||||
|
CF_API="https://api.cloudflare.com/client/v4"
|
||||||
|
CF_ZONE="$CLOUDFLARE_ZONE_ID"
|
||||||
|
CF_TOKEN="$CLOUDFLARE_API_TOKEN"
|
||||||
|
|
||||||
|
echo "[certctl DNS-01] Cleaning up DNS record: $RECORD_NAME"
|
||||||
|
|
||||||
|
# Step 1: Find the record ID
|
||||||
|
RECORD_ID=$(curl -s -X GET \
|
||||||
|
"$CF_API/zones/$CF_ZONE/dns_records?name=$RECORD_NAME&type=$RECORD_TYPE" \
|
||||||
|
-H "Authorization: Bearer $CF_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
| jq -r '.result | if length > 0 then .[0].id else "" end')
|
||||||
|
|
||||||
|
if [[ -z "$RECORD_ID" ]]; then
|
||||||
|
echo "[certctl DNS-01] Record not found (already deleted?). Skipping cleanup."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 2: Delete the record (DELETE /zones/{zone_id}/dns_records/{record_id})
|
||||||
|
echo "[certctl DNS-01] Deleting DNS record (ID: $RECORD_ID)..."
|
||||||
|
RESPONSE=$(curl -s -X DELETE \
|
||||||
|
"$CF_API/zones/$CF_ZONE/dns_records/$RECORD_ID" \
|
||||||
|
-H "Authorization: Bearer $CF_TOKEN" \
|
||||||
|
-H "Content-Type: application/json")
|
||||||
|
|
||||||
|
# Check response success
|
||||||
|
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
|
||||||
|
if [[ "$SUCCESS" != "true" ]]; then
|
||||||
|
ERROR=$(echo "$RESPONSE" | jq -r '.errors[0].message // "Unknown error"')
|
||||||
|
echo "Warning: Cloudflare API failed to delete record: $ERROR" >&2
|
||||||
|
# Don't exit 1 here — DNS cleanup is best-effort; cleanup failures shouldn't block certs
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[certctl DNS-01] Successfully deleted DNS record"
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
#
|
||||||
|
# Cloudflare DNS-01 Challenge Script (PRESENT)
|
||||||
|
#
|
||||||
|
# This script creates a DNS TXT record for ACME DNS-01 challenge validation.
|
||||||
|
# Called by certctl during the renewal process to prove domain ownership.
|
||||||
|
#
|
||||||
|
# certctl sets these environment variables before invoking this script:
|
||||||
|
# CERTCTL_DNS_DOMAIN - Base domain (e.g., "example.com")
|
||||||
|
# CERTCTL_DNS_FQDN - Full challenge FQDN (e.g., "_acme-challenge.example.com")
|
||||||
|
# CERTCTL_DNS_VALUE - Challenge value/token to place in the TXT record
|
||||||
|
#
|
||||||
|
# You must set these environment variables before running:
|
||||||
|
# CLOUDFLARE_API_TOKEN - Cloudflare API token with DNS:write permission
|
||||||
|
# CLOUDFLARE_ZONE_ID - Cloudflare zone ID for your domain
|
||||||
|
# (Find at: https://dash.cloudflare.com > Select Domain > Zone ID in sidebar)
|
||||||
|
#
|
||||||
|
# Error Handling:
|
||||||
|
# This script exits 0 on success, non-zero on failure.
|
||||||
|
# certctl will retry the renewal if this script fails.
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Get values from certctl environment variables
|
||||||
|
DOMAIN="${CERTCTL_DNS_DOMAIN:-}"
|
||||||
|
RECORD_NAME="${CERTCTL_DNS_FQDN:-}"
|
||||||
|
VALIDATION_TOKEN="${CERTCTL_DNS_VALUE:-}"
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
if [[ -z "$DOMAIN" || -z "$RECORD_NAME" || -z "$VALIDATION_TOKEN" ]]; then
|
||||||
|
echo "Error: Required certctl environment variables not set (CERTCTL_DNS_DOMAIN, CERTCTL_DNS_FQDN, CERTCTL_DNS_VALUE)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate environment
|
||||||
|
if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then
|
||||||
|
echo "Error: CLOUDFLARE_API_TOKEN environment variable not set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${CLOUDFLARE_ZONE_ID:-}" ]]; then
|
||||||
|
echo "Error: CLOUDFLARE_ZONE_ID environment variable not set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate RECORD_NAME (set by certctl above)
|
||||||
|
RECORD_TYPE="TXT"
|
||||||
|
RECORD_TTL=120 # Short TTL for challenge records (1-2 min)
|
||||||
|
|
||||||
|
# Cloudflare API endpoint
|
||||||
|
CF_API="https://api.cloudflare.com/client/v4"
|
||||||
|
CF_ZONE="$CLOUDFLARE_ZONE_ID"
|
||||||
|
CF_TOKEN="$CLOUDFLARE_API_TOKEN"
|
||||||
|
|
||||||
|
echo "[certctl DNS-01] Creating DNS record: $RECORD_NAME = $VALIDATION_TOKEN"
|
||||||
|
|
||||||
|
# Step 1: Check if record already exists (GET /zones/{zone_id}/dns_records)
|
||||||
|
# This is optional but helps with idempotency
|
||||||
|
EXISTING=$(curl -s -X GET \
|
||||||
|
"$CF_API/zones/$CF_ZONE/dns_records?name=$RECORD_NAME&type=$RECORD_TYPE" \
|
||||||
|
-H "Authorization: Bearer $CF_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
| jq -r '.result | if length > 0 then .[0].id else "" end')
|
||||||
|
|
||||||
|
if [[ -n "$EXISTING" ]]; then
|
||||||
|
echo "[certctl DNS-01] Record already exists (ID: $EXISTING). Updating..."
|
||||||
|
# Update existing record
|
||||||
|
RESPONSE=$(curl -s -X PUT \
|
||||||
|
"$CF_API/zones/$CF_ZONE/dns_records/$EXISTING" \
|
||||||
|
-H "Authorization: Bearer $CF_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"type\": \"$RECORD_TYPE\",
|
||||||
|
\"name\": \"$RECORD_NAME\",
|
||||||
|
\"content\": \"$VALIDATION_TOKEN\",
|
||||||
|
\"ttl\": $RECORD_TTL
|
||||||
|
}")
|
||||||
|
else
|
||||||
|
echo "[certctl DNS-01] Creating new DNS record..."
|
||||||
|
# Create new record
|
||||||
|
RESPONSE=$(curl -s -X POST \
|
||||||
|
"$CF_API/zones/$CF_ZONE/dns_records" \
|
||||||
|
-H "Authorization: Bearer $CF_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"type\": \"$RECORD_TYPE\",
|
||||||
|
\"name\": \"$RECORD_NAME\",
|
||||||
|
\"content\": \"$VALIDATION_TOKEN\",
|
||||||
|
\"ttl\": $RECORD_TTL
|
||||||
|
}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check response success
|
||||||
|
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
|
||||||
|
if [[ "$SUCCESS" != "true" ]]; then
|
||||||
|
ERROR=$(echo "$RESPONSE" | jq -r '.errors[0].message // "Unknown error"')
|
||||||
|
echo "Error: Cloudflare API failed: $ERROR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RECORD_ID=$(echo "$RESPONSE" | jq -r '.result.id')
|
||||||
|
echo "[certctl DNS-01] Successfully created/updated DNS record (ID: $RECORD_ID)"
|
||||||
|
echo "[certctl DNS-01] Waiting for DNS propagation..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# ACME Wildcard DNS-01 Example
|
||||||
|
#
|
||||||
|
# This example demonstrates how to use certctl with Let's Encrypt to issue wildcard
|
||||||
|
# certificates (*.example.com) using DNS-01 challenge validation.
|
||||||
|
#
|
||||||
|
# DNS-01 is ideal for:
|
||||||
|
# - Wildcard certificates (*.domain.com)
|
||||||
|
# - Services behind NAT or non-public networks
|
||||||
|
# - Batch certificate issuance (multiple domains in parallel)
|
||||||
|
#
|
||||||
|
# It works by:
|
||||||
|
# 1. certctl creates a renewal job for a wildcard certificate
|
||||||
|
# 2. Let's Encrypt sends an ACME challenge: "create _acme-challenge TXT record with value X"
|
||||||
|
# 3. certctl runs the dns-present.sh script to create the TXT record via your DNS provider API
|
||||||
|
# 4. Let's Encrypt verifies the TXT record exists
|
||||||
|
# 5. Certificate is issued
|
||||||
|
# 6. certctl runs dns-cleanup.sh to remove the TXT record
|
||||||
|
#
|
||||||
|
# This compose file also demonstrates:
|
||||||
|
# - ACME issuer with DNS-01 challenge type
|
||||||
|
# - Pluggable DNS provider scripts (Cloudflare example included; adapt for Route53, Azure DNS, etc.)
|
||||||
|
# - Wildcard and multi-SAN certificate support
|
||||||
|
# - Agent-side key generation (production-ready)
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL database for certctl metadata
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: certctl-postgres-dns01
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: certctl
|
||||||
|
POSTGRES_USER: certctl
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-certctl-dev-password}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U certctl -d certctl']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# certctl server (control plane + ACME orchestration)
|
||||||
|
certctl-server:
|
||||||
|
image: ghcr.io/shankar0123/certctl-server:latest
|
||||||
|
container_name: certctl-server-dns01
|
||||||
|
environment:
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
CERTCTL_SERVER_PORT: 8443
|
||||||
|
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||||
|
|
||||||
|
# Auth (disabled for demo; production should use API keys with CERTCTL_AUTH_TYPE=api-key)
|
||||||
|
CERTCTL_AUTH_TYPE: none
|
||||||
|
|
||||||
|
# CORS (allow agent communication)
|
||||||
|
CERTCTL_CORS_ORIGINS: '*'
|
||||||
|
|
||||||
|
# Key generation mode (agent-side: keys never leave agents; production standard)
|
||||||
|
CERTCTL_KEYGEN_MODE: agent
|
||||||
|
|
||||||
|
# ===== ACME Issuer Configuration (DNS-01 Wildcard) =====
|
||||||
|
# Let's Encrypt production directory (ACME v2)
|
||||||
|
CERTCTL_ACME_DIRECTORY_URL: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
|
||||||
|
# Email for certificate expiration notices and account recovery
|
||||||
|
CERTCTL_ACME_EMAIL: ${ACME_EMAIL:-admin@example.com}
|
||||||
|
|
||||||
|
# Challenge type: dns-01 (not http-01, which doesn't support wildcards)
|
||||||
|
CERTCTL_ACME_CHALLENGE_TYPE: dns-01
|
||||||
|
|
||||||
|
# DNS present script: creates _acme-challenge TXT record
|
||||||
|
# The script is mounted from ./dns-hooks/cloudflare-present.sh
|
||||||
|
# Arguments: $1 = domain (e.g., "example.com"), $2 = validation token
|
||||||
|
CERTCTL_ACME_DNS_PRESENT_SCRIPT: /etc/certctl/dns-hooks/cloudflare-present.sh
|
||||||
|
|
||||||
|
# DNS cleanup script: removes _acme-challenge TXT record
|
||||||
|
# Arguments: $1 = domain, $2 = validation token
|
||||||
|
CERTCTL_ACME_DNS_CLEANUP_SCRIPT: /etc/certctl/dns-hooks/cloudflare-cleanup.sh
|
||||||
|
|
||||||
|
# Optional: DNS propagation wait time (seconds) before proceeding to next challenge
|
||||||
|
# Default is 30s; increase if your DNS propagates slowly
|
||||||
|
# Set via CERTCTL_ACME_DNS_PROPAGATION_WAIT in code, or rely on default
|
||||||
|
|
||||||
|
# Optional: Let's Encrypt Renewal Information (RFC 9702) for CA-directed renewal timing
|
||||||
|
# CERTCTL_ACME_ARI_ENABLED: "true"
|
||||||
|
|
||||||
|
# Local CA as fallback for internal services (optional)
|
||||||
|
CERTCTL_CA_CERT_PATH: /etc/certctl/ca.crt
|
||||||
|
CERTCTL_CA_KEY_PATH: /etc/certctl/ca.key
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
CERTCTL_LOG_LEVEL: info
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- '${SERVER_PORT:-8443}:8443'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Mount DNS provider scripts (adapt these for your DNS provider)
|
||||||
|
- ./dns-hooks:/etc/certctl/dns-hooks:ro
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# certctl agent (manages certificate deployment on target hosts)
|
||||||
|
# In production, run agents on each host that needs certificates.
|
||||||
|
# For demo, we include one agent in this compose.
|
||||||
|
certctl-agent:
|
||||||
|
image: ghcr.io/shankar0123/certctl-agent:latest
|
||||||
|
container_name: certctl-agent-dns01
|
||||||
|
environment:
|
||||||
|
# Control plane connection
|
||||||
|
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||||
|
CERTCTL_API_KEY: ${AGENT_API_KEY:-agent-demo-key}
|
||||||
|
|
||||||
|
# Key generation (agent-side keys: production-standard security model)
|
||||||
|
CERTCTL_KEYGEN_MODE: agent
|
||||||
|
CERTCTL_KEY_DIR: /var/lib/certctl/keys
|
||||||
|
|
||||||
|
# Discovery (scan existing certs so operator knows what's already deployed)
|
||||||
|
CERTCTL_DISCOVERY_DIRS: /etc/letsencrypt/live:/etc/ssl/certs
|
||||||
|
|
||||||
|
# Heartbeat interval (how often agent checks for work)
|
||||||
|
CERTCTL_HEARTBEAT_INTERVAL: 30s
|
||||||
|
|
||||||
|
# Agent metadata (self-reported to server)
|
||||||
|
CERTCTL_AGENT_NAME: wildcard-agent-01
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
CERTCTL_LOG_LEVEL: info
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Agent persistent key storage (survives restarts)
|
||||||
|
- agent_keys:/var/lib/certctl/keys
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
certctl-server:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
certctl-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
agent_keys:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL database for certctl
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: certctl-postgres-multi-issuer
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: certctl
|
||||||
|
POSTGRES_USER: certctl
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-certctl-dev-password}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U certctl -d certctl']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# certctl server (control plane)
|
||||||
|
# Configured with BOTH ACME (Let's Encrypt) and Local CA issuers
|
||||||
|
certctl-server:
|
||||||
|
image: ghcr.io/shankar0123/certctl-server:latest
|
||||||
|
container_name: certctl-server-multi-issuer
|
||||||
|
environment:
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
CERTCTL_SERVER_PORT: 8443
|
||||||
|
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||||
|
|
||||||
|
# Auth (disabled for demo; production should use API keys)
|
||||||
|
CERTCTL_AUTH_TYPE: none
|
||||||
|
|
||||||
|
# CORS (allow agent communication)
|
||||||
|
CERTCTL_CORS_ORIGINS: '*'
|
||||||
|
|
||||||
|
# Key generation mode (agent-side in production, server-side for demo)
|
||||||
|
CERTCTL_KEYGEN_MODE: server
|
||||||
|
|
||||||
|
# ACME issuer (Let's Encrypt for public-facing services)
|
||||||
|
# Change CERTCTL_ACME_EMAIL to your email and CERTCTL_ACME_CHALLENGE_TYPE as needed
|
||||||
|
CERTCTL_ACME_DIRECTORY_URL: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
CERTCTL_ACME_EMAIL: ${ACME_EMAIL:-admin@example.com}
|
||||||
|
CERTCTL_ACME_CHALLENGE_TYPE: http-01
|
||||||
|
|
||||||
|
# Local CA issuer (for internal services - self-signed or sub-CA)
|
||||||
|
# Set these paths if you have an existing CA cert+key for sub-CA mode
|
||||||
|
# Otherwise, leave empty for self-signed CA generation
|
||||||
|
CERTCTL_CA_CERT_PATH: ${CA_CERT_PATH:-}
|
||||||
|
CERTCTL_CA_KEY_PATH: ${CA_KEY_PATH:-}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
CERTCTL_LOG_LEVEL: info
|
||||||
|
ports:
|
||||||
|
- '${SERVER_PORT:-8443}:8443'
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# certctl agent (manages certificates on NGINX and application servers)
|
||||||
|
certctl-agent:
|
||||||
|
image: ghcr.io/shankar0123/certctl-agent:latest
|
||||||
|
container_name: certctl-agent-multi-issuer
|
||||||
|
environment:
|
||||||
|
# Control plane connection
|
||||||
|
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||||
|
CERTCTL_API_KEY: ${AGENT_API_KEY:-agent-demo-key}
|
||||||
|
|
||||||
|
# Key generation (agent-side keys, never sent to server)
|
||||||
|
CERTCTL_KEYGEN_MODE: server
|
||||||
|
CERTCTL_KEY_DIR: /var/lib/certctl/keys
|
||||||
|
|
||||||
|
# Discovery (scan existing certs to track what's already deployed)
|
||||||
|
CERTCTL_DISCOVERY_DIRS: /etc/nginx/ssl:/etc/app/ssl
|
||||||
|
|
||||||
|
# Heartbeat interval
|
||||||
|
CERTCTL_HEARTBEAT_INTERVAL: 30s
|
||||||
|
|
||||||
|
# Agent metadata
|
||||||
|
CERTCTL_AGENT_NAME: multi-issuer-agent-01
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
CERTCTL_LOG_LEVEL: info
|
||||||
|
volumes:
|
||||||
|
# Mount NGINX cert directories
|
||||||
|
- nginx_certs:/etc/nginx/ssl
|
||||||
|
- nginx_conf:/etc/nginx/conf.d
|
||||||
|
# Mount application service cert directory
|
||||||
|
- app_certs:/etc/app/ssl
|
||||||
|
# Agent key storage (persisted across restarts)
|
||||||
|
- agent_keys:/var/lib/certctl/keys
|
||||||
|
depends_on:
|
||||||
|
certctl-server:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# NGINX reverse proxy / web server
|
||||||
|
# This is where public TLS certs (from ACME) will be deployed
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: certctl-nginx-multi-issuer
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
- '443:443'
|
||||||
|
volumes:
|
||||||
|
- nginx_conf:/etc/nginx/conf.d
|
||||||
|
- nginx_certs:/etc/nginx/ssl
|
||||||
|
# Default NGINX config
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- certctl-agent
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost/ || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
certctl-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
nginx_certs:
|
||||||
|
driver: local
|
||||||
|
nginx_conf:
|
||||||
|
driver: local
|
||||||
|
app_certs:
|
||||||
|
driver: local
|
||||||
|
agent_keys:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Multi-Issuer Example: ACME + Local CA
|
||||||
|
|
||||||
|
This example demonstrates certctl managing **both public and internal certificates from a single dashboard**. Public-facing services use Let's Encrypt (ACME), while internal services use a private Local CA — all visible and managed in one place.
|
||||||
|
|
||||||
|
## The Use Case
|
||||||
|
|
||||||
|
You have:
|
||||||
|
- **Public-facing services** (web app, API, etc.) that need TLS certs signed by a trusted public CA (Let's Encrypt)
|
||||||
|
- **Internal services** (databases, microservices, middleware) that need TLS certs but don't require public trust
|
||||||
|
- **One team** managing certs across both, needing unified visibility and automated renewal
|
||||||
|
|
||||||
|
With certctl, both issuer types are configured and available. You assign each certificate to the appropriate issuer via its profile or at enrollment time. The dashboard shows all certs together, with renewal status, expiration timelines, and audit trails — regardless of which CA issued them.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ certctl Server (Control Plane) │
|
||||||
|
│ - Let's Encrypt ACME issuer (HTTP-01 challenges) │
|
||||||
|
│ - Local CA issuer (self-signed or sub-CA mode) │
|
||||||
|
│ - PostgreSQL database (cert inventory, audit, jobs) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ API polling
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ certctl Agent │
|
||||||
|
│ - Discovers existing certs in /etc/nginx/ssl and /etc/app/ssl │
|
||||||
|
│ - Polls server for renewal/issuance/deployment jobs │
|
||||||
|
│ - Generates keys locally (agent-side crypto) │
|
||||||
|
│ - Deploys certs to NGINX and app service directories │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
NGINX (public TLS) App Services (internal TLS)
|
||||||
|
(Let's Encrypt certs) (Local CA certs)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Docker & Docker Compose** — containers run everything
|
||||||
|
- **Port access** — 80 (HTTP-01 challenges) and 443 (HTTPS) for Let's Encrypt
|
||||||
|
- **Domain for ACME** (optional) — if using real Let's Encrypt, not needed for demo
|
||||||
|
- **Internet connectivity** — to reach Let's Encrypt's API (demo can use staging directory)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Clone or navigate to this directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/multi-issuer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set environment variables (optional, defaults provided)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Email for Let's Encrypt account
|
||||||
|
export ACME_EMAIL="your-email@example.com"
|
||||||
|
|
||||||
|
# Database password (for demo, default is fine)
|
||||||
|
export DB_PASSWORD="certctl-dev-password"
|
||||||
|
|
||||||
|
# Agent API key
|
||||||
|
export AGENT_API_KEY="agent-demo-key"
|
||||||
|
|
||||||
|
# Server port (default 8443)
|
||||||
|
export SERVER_PORT="8443"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This spins up:
|
||||||
|
- **PostgreSQL** database (certctl data store)
|
||||||
|
- **certctl server** with ACME and Local CA issuers configured
|
||||||
|
- **certctl agent** discovering existing certs and polling for work
|
||||||
|
- **NGINX** web server (target for public TLS certs)
|
||||||
|
|
||||||
|
### 4. Access the dashboard
|
||||||
|
|
||||||
|
Open your browser to **http://localhost:8443** (or your configured SERVER_PORT)
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
- Empty cert inventory (fresh start)
|
||||||
|
- Two configured issuers: "ACME" and "Local CA"
|
||||||
|
- One registered agent ("multi-issuer-agent-01")
|
||||||
|
|
||||||
|
### 5. Create test certificates
|
||||||
|
|
||||||
|
In the dashboard:
|
||||||
|
|
||||||
|
**For a public cert (Let's Encrypt):**
|
||||||
|
1. Go to **Certificates** > **+ New Certificate**
|
||||||
|
2. Common Name: `example.com` (or a test domain you control)
|
||||||
|
3. Issuer: Select "ACME"
|
||||||
|
4. Profile: Select default or create one (key type: RSA 2048, TTL: 90 days)
|
||||||
|
5. Create → The server submits an ACME order
|
||||||
|
|
||||||
|
**For an internal cert (Local CA):**
|
||||||
|
1. Go to **Certificates** > **+ New Certificate**
|
||||||
|
2. Common Name: `internal-api.internal` (or any internal name)
|
||||||
|
3. Issuer: Select "Local CA"
|
||||||
|
4. Profile: Select default
|
||||||
|
5. Create → The server issues immediately from the private CA
|
||||||
|
|
||||||
|
### 6. Monitor in the dashboard
|
||||||
|
|
||||||
|
- **Dashboard** — see cert counts by status and issuer
|
||||||
|
- **Certificates** page — filter by issuer, see renewal status, expiration timeline
|
||||||
|
- **Audit Trail** — track all operations (issuance, renewals, deployments)
|
||||||
|
- **Agents** — view agent health and pending work
|
||||||
|
|
||||||
|
## How Issuer Assignment Works
|
||||||
|
|
||||||
|
### Via Profiles
|
||||||
|
Create a profile for each issuer type:
|
||||||
|
- Profile **public-tls** → Issuer: ACME, TTL: 90 days, allowed domains: `*.example.com`
|
||||||
|
- Profile **internal-tls** → Issuer: Local CA, TTL: 1 year, allowed SANs: internal DNS names
|
||||||
|
|
||||||
|
Then create certificates using the appropriate profile.
|
||||||
|
|
||||||
|
### Via Direct Assignment
|
||||||
|
When creating a certificate, explicitly select the issuer. The certificate remembers which issuer it belongs to.
|
||||||
|
|
||||||
|
## ACME Configuration
|
||||||
|
|
||||||
|
The server is configured with Let's Encrypt's production directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
CERTCTL_ACME_DIRECTORY_URL: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
CERTCTL_ACME_EMAIL: admin@example.com
|
||||||
|
CERTCTL_ACME_CHALLENGE_TYPE: http-01
|
||||||
|
```
|
||||||
|
|
||||||
|
**For testing without a real domain**, use Let's Encrypt's staging directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit docker-compose.yml and change:
|
||||||
|
CERTCTL_ACME_DIRECTORY_URL: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
```
|
||||||
|
|
||||||
|
Staging certs are untrusted (for testing only) but unlimited rate limits.
|
||||||
|
|
||||||
|
## Local CA Configuration
|
||||||
|
|
||||||
|
The Local CA issuer can operate in two modes:
|
||||||
|
|
||||||
|
### Mode 1: Self-Signed (Default)
|
||||||
|
Leave `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH` empty. The server generates a self-signed root CA on first run.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
CERTCTL_CA_CERT_PATH: ""
|
||||||
|
CERTCTL_CA_KEY_PATH: ""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use case:** Development, testing, internal services that trust a self-signed root.
|
||||||
|
|
||||||
|
### Mode 2: Sub-CA (Enterprise)
|
||||||
|
Provide an existing CA cert + key (e.g., from your organization's PKI). The Local CA issues certs signed by that intermediate.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In docker-compose.yml, volume-mount your CA cert+key:
|
||||||
|
volumes:
|
||||||
|
- /path/to/ca.crt:/etc/certctl/ca.crt:ro
|
||||||
|
- /path/to/ca.key:/etc/certctl/ca.key:ro
|
||||||
|
|
||||||
|
# And set env vars:
|
||||||
|
CERTCTL_CA_CERT_PATH: /etc/certctl/ca.crt
|
||||||
|
CERTCTL_CA_KEY_PATH: /etc/certctl/ca.key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use case:** Enterprise internal PKI where certs need to chain to a trusted root (e.g., Windows ADCS, OpenSSL, Vault PKI).
|
||||||
|
|
||||||
|
## Deployment Flow
|
||||||
|
|
||||||
|
When you create a certificate and assign it for deployment:
|
||||||
|
|
||||||
|
1. **Issuance** — Server calls the issuer connector (ACME or Local CA)
|
||||||
|
- ACME: submit challenge, poll until DNS/HTTP validated, retrieve cert
|
||||||
|
- Local CA: generate and sign immediately
|
||||||
|
|
||||||
|
2. **Agent picks up work** — Agent polls `/api/v1/agents/{id}/work`
|
||||||
|
|
||||||
|
3. **Agent deployment** — Agent places cert+key in the target directory
|
||||||
|
- NGINX: `/etc/nginx/ssl/` (mounted volume)
|
||||||
|
- App services: `/etc/app/ssl/` (mounted volume)
|
||||||
|
|
||||||
|
4. **Service reload** — Agent triggers reload (NGINX: `nginx -s reload`, etc.)
|
||||||
|
|
||||||
|
5. **Dashboard reflects status** — Job transitions from `Running` → `Completed`, cert shows as `Active`
|
||||||
|
|
||||||
|
## Scaling Beyond Docker Compose
|
||||||
|
|
||||||
|
In production:
|
||||||
|
|
||||||
|
- **Deploy certctl server** on a single node (or HA cluster with external PostgreSQL)
|
||||||
|
- **Deploy certctl agents** on each server needing cert management
|
||||||
|
- **Point agents to server URL** via `CERTCTL_SERVER_URL` env var
|
||||||
|
- **Configure issuers on server** via env vars or (in V3+) the dashboard UI
|
||||||
|
- **Use profiles to segment issuers** — operators select a profile at cert creation time
|
||||||
|
|
||||||
|
Each agent independently manages its local cert inventory and deployments. The server coordinates all agent work and provides the unified dashboard.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Certs aren't being issued
|
||||||
|
- Check server logs: `docker compose logs certctl-server`
|
||||||
|
- Verify issuer configuration: Dashboard → Issuers, click "Test Connection"
|
||||||
|
- For ACME, ensure ports 80/443 are open and your domain resolves
|
||||||
|
|
||||||
|
### Agent can't reach server
|
||||||
|
- Check network: `docker compose exec certctl-agent curl http://certctl-server:8443/api/v1/health`
|
||||||
|
- Verify `CERTCTL_SERVER_URL` environment variable
|
||||||
|
|
||||||
|
### No issuers showing up
|
||||||
|
- Ensure env vars are set on the server container
|
||||||
|
- Restart server: `docker compose restart certctl-server`
|
||||||
|
- Check server logs for validation errors
|
||||||
|
|
||||||
|
### Let's Encrypt rate limits
|
||||||
|
- Use the staging directory for testing (unlimited, untrusted certs)
|
||||||
|
- Production directory: 50 certs per domain per week
|
||||||
|
- Read more: https://letsencrypt.org/docs/rate-limits/
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- **Create a certificate profile** — Dashboard → Profiles → + New Profile
|
||||||
|
- **Configure team ownership** — Dashboard → Owners/Teams (assign certs to teams)
|
||||||
|
- **Set renewal policies** — Dashboard → Policies (expiration thresholds, auto-renewal)
|
||||||
|
- **Enable notifications** — Configure Slack/Teams webhook to get alerts on renewals and expirations
|
||||||
|
- **Explore discovery** — Agent scans `/etc/nginx/ssl` and `/etc/app/ssl`, Dashboard → Discovery shows what's already deployed
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- [certctl Architecture](../../docs/architecture.md)
|
||||||
|
- [ACME Connector Docs](../../docs/connectors.md#acme-letsencrypt)
|
||||||
|
- [Local CA Connector Docs](../../docs/connectors.md#local-ca)
|
||||||
|
- [Agent Configuration](../../docs/agent.md)
|
||||||
|
- [Deployment Targets](../../docs/connectors.md#deployment-targets)
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL database for certctl
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: certctl-postgres-private-ca
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: certctl
|
||||||
|
POSTGRES_USER: certctl
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-certctl-dev-password}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U certctl -d certctl']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# certctl server (control plane) with Local CA in sub-CA mode
|
||||||
|
certctl-server:
|
||||||
|
image: ghcr.io/shankar0123/certctl-server:latest
|
||||||
|
container_name: certctl-server-private-ca
|
||||||
|
environment:
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
CERTCTL_SERVER_PORT: 8443
|
||||||
|
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||||
|
|
||||||
|
# Auth (disabled for demo; production should use API keys)
|
||||||
|
CERTCTL_AUTH_TYPE: none
|
||||||
|
|
||||||
|
# CORS (allow agent and Traefik communication)
|
||||||
|
CERTCTL_CORS_ORIGINS: '*'
|
||||||
|
|
||||||
|
# Key generation mode (agent-side in production, server-side for demo)
|
||||||
|
CERTCTL_KEYGEN_MODE: server
|
||||||
|
|
||||||
|
# Local CA configuration
|
||||||
|
# For self-signed CA (default, no paths set):
|
||||||
|
# - CA generates a self-signed root certificate
|
||||||
|
# - All issued certificates chain to this root
|
||||||
|
#
|
||||||
|
# For sub-CA mode (provide both paths):
|
||||||
|
# - Load pre-signed CA certificate and key from these paths
|
||||||
|
# - All issued certificates chain to your enterprise root CA
|
||||||
|
# - Requires: CA cert must have IsCA=true and KeyUsageCertSign
|
||||||
|
# - Supports: RSA, ECDSA, PKCS#8 key formats
|
||||||
|
#
|
||||||
|
# To use sub-CA mode:
|
||||||
|
# 1. Place your enterprise CA cert at ./ca-cert.pem
|
||||||
|
# 2. Place your enterprise CA key at ./ca-key.pem
|
||||||
|
# 3. Uncomment the two lines below
|
||||||
|
# 4. Restart the service
|
||||||
|
#
|
||||||
|
# CERTCTL_CA_CERT_PATH: /etc/certctl/ca-cert.pem
|
||||||
|
# CERTCTL_CA_KEY_PATH: /etc/certctl/ca-key.pem
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
CERTCTL_LOG_LEVEL: info
|
||||||
|
ports:
|
||||||
|
- '${SERVER_PORT:-8443}:8443'
|
||||||
|
volumes:
|
||||||
|
# Mount directory for CA cert/key (for sub-CA mode)
|
||||||
|
# Copy your enterprise CA cert+key here:
|
||||||
|
# cp /path/to/your/ca.pem ./ca-cert.pem
|
||||||
|
# cp /path/to/your/ca-key.pem ./ca-key.pem
|
||||||
|
- ./ca-certs:/etc/certctl:ro
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# certctl agent (deploys certs to Traefik)
|
||||||
|
certctl-agent:
|
||||||
|
image: ghcr.io/shankar0123/certctl-agent:latest
|
||||||
|
container_name: certctl-agent-private-ca
|
||||||
|
environment:
|
||||||
|
# Control plane connection
|
||||||
|
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||||
|
CERTCTL_API_KEY: ${AGENT_API_KEY:-agent-demo-key}
|
||||||
|
|
||||||
|
# Key generation (agent-side keys, never sent to server)
|
||||||
|
CERTCTL_KEYGEN_MODE: server
|
||||||
|
CERTCTL_KEY_DIR: /var/lib/certctl/keys
|
||||||
|
|
||||||
|
# Discovery (scan for existing certs in Traefik's directory)
|
||||||
|
CERTCTL_DISCOVERY_DIRS: /etc/traefik/certs
|
||||||
|
|
||||||
|
# Heartbeat interval
|
||||||
|
CERTCTL_HEARTBEAT_INTERVAL: 30s
|
||||||
|
|
||||||
|
# Agent metadata (self-reported)
|
||||||
|
CERTCTL_AGENT_NAME: traefik-agent-01
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
CERTCTL_LOG_LEVEL: info
|
||||||
|
volumes:
|
||||||
|
# Mount Traefik cert directory for deployment
|
||||||
|
- traefik_certs:/etc/traefik/certs
|
||||||
|
# Agent key storage (persisted across restarts)
|
||||||
|
- agent_keys:/var/lib/certctl/keys
|
||||||
|
depends_on:
|
||||||
|
certctl-server:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Traefik reverse proxy / edge router
|
||||||
|
# Certificates deployed by certctl-agent are automatically loaded from the certs directory
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.0
|
||||||
|
container_name: certctl-traefik-private-ca
|
||||||
|
command:
|
||||||
|
# Enable dashboard and API
|
||||||
|
- '--api.insecure=true'
|
||||||
|
- '--api.dashboard=true'
|
||||||
|
|
||||||
|
# File provider: watch the certs directory for dynamic config updates
|
||||||
|
- '--providers.file.directory=/etc/traefik/dynamic'
|
||||||
|
- '--providers.file.watch=true'
|
||||||
|
|
||||||
|
# Entry points (HTTP and HTTPS)
|
||||||
|
- '--entrypoints.web.address=:80'
|
||||||
|
- '--entrypoints.websecure.address=:443'
|
||||||
|
- '--entrypoints.websecure.http.tls=true'
|
||||||
|
|
||||||
|
# Global TLS settings
|
||||||
|
- '--entryPoints.websecure.http.tls.certResolver=internal'
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
- '--log.level=info'
|
||||||
|
- '--accesslog=true'
|
||||||
|
ports:
|
||||||
|
# HTTP
|
||||||
|
- '80:80'
|
||||||
|
# HTTPS
|
||||||
|
- '443:443'
|
||||||
|
# Dashboard (http://localhost:8080)
|
||||||
|
- '8080:8080'
|
||||||
|
volumes:
|
||||||
|
# Mount Traefik config directory
|
||||||
|
- ./traefik-config:/etc/traefik/dynamic:ro
|
||||||
|
# Mount cert directory (where certctl deploys certs)
|
||||||
|
- traefik_certs:/etc/traefik/certs:ro
|
||||||
|
# Allow Traefik to read Docker socket (optional, for container labeling)
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
depends_on:
|
||||||
|
- certctl-agent
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8080/ping || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
certctl-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
traefik_certs:
|
||||||
|
driver: local
|
||||||
|
agent_keys:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# Private CA + Traefik Example
|
||||||
|
|
||||||
|
This example demonstrates certctl managing certificates for **internal services without public CA dependency**. Ideal for enterprise environments where:
|
||||||
|
|
||||||
|
- All services are internal (VPN, private networks)
|
||||||
|
- You need unified certificate lifecycle management across multiple internal apps
|
||||||
|
- You want automatic cert deployment to your reverse proxy
|
||||||
|
- You may have an existing enterprise root CA (ADCS, OpenCA, etc.)
|
||||||
|
|
||||||
|
## What's Included
|
||||||
|
|
||||||
|
- **certctl server** with Local CA issuer (self-signed or sub-CA mode)
|
||||||
|
- **certctl agent** that deploys certificates to Traefik
|
||||||
|
- **Traefik** reverse proxy with file provider for dynamic cert discovery
|
||||||
|
- **PostgreSQL** database for certificate storage and audit trail
|
||||||
|
- Automatic certificate discovery for existing certs in Traefik
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐
|
||||||
|
│ certctl-server │ (Local CA issuer)
|
||||||
|
│ (control │
|
||||||
|
│ plane) │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
│ REST API (job polling)
|
||||||
|
│
|
||||||
|
┌────────▼──────────┐
|
||||||
|
│ certctl-agent │ (certificate deployer)
|
||||||
|
└────────┬──────────┘
|
||||||
|
│
|
||||||
|
│ Write cert/key files
|
||||||
|
│
|
||||||
|
┌────────▼──────────────────────┐
|
||||||
|
│ Traefik │
|
||||||
|
│ (watches cert directory) │
|
||||||
|
└────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ TLS handshakes
|
||||||
|
│
|
||||||
|
[Internal Services]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start (Self-Signed CA)
|
||||||
|
|
||||||
|
The simplest way to get running in 2 minutes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create directory structure
|
||||||
|
mkdir -p traefik-config ca-certs
|
||||||
|
|
||||||
|
# 2. Create a minimal Traefik dynamic config
|
||||||
|
cat > traefik-config/default.yaml << 'EOF'
|
||||||
|
# Traefik will auto-load certificates from /etc/traefik/certs
|
||||||
|
# Certctl deploys {cert-id}.crt and {cert-id}.key files here
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
api:
|
||||||
|
rule: "Host(`api.internal.local`)"
|
||||||
|
service: api-service
|
||||||
|
tls: {}
|
||||||
|
services:
|
||||||
|
api-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://localhost:3000"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 3. Start the stack
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 4. Access the dashboards
|
||||||
|
# - certctl: http://localhost:8443 (API only, use the CLI or direct HTTP calls)
|
||||||
|
# - Traefik dashboard: http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The self-signed CA will be automatically generated on first startup.
|
||||||
|
|
||||||
|
## Using Sub-CA Mode (Enterprise Root CA)
|
||||||
|
|
||||||
|
If you have an existing enterprise CA (ADCS, OpenCA, etc.) and want issued certs to chain to your root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create directory structure
|
||||||
|
mkdir -p traefik-config ca-certs
|
||||||
|
|
||||||
|
# 2. Copy your enterprise CA cert and key
|
||||||
|
cp /path/to/your/enterprise-ca.crt ca-certs/ca-cert.pem
|
||||||
|
cp /path/to/your/enterprise-ca-key.pem ca-certs/ca-key.pem
|
||||||
|
|
||||||
|
# 3. Edit docker-compose.yml and uncomment the sub-CA env vars:
|
||||||
|
# CERTCTL_CA_CERT_PATH: /etc/certctl/ca-cert.pem
|
||||||
|
# CERTCTL_CA_KEY_PATH: /etc/certctl/ca-key.pem
|
||||||
|
|
||||||
|
# 4. Create the dynamic config (same as above)
|
||||||
|
mkdir -p traefik-config
|
||||||
|
cat > traefik-config/default.yaml << 'EOF'
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
api:
|
||||||
|
rule: "Host(`api.internal.local`)"
|
||||||
|
service: api-service
|
||||||
|
tls: {}
|
||||||
|
services:
|
||||||
|
api-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://localhost:3000"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 5. Start the stack
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements for sub-CA mode:**
|
||||||
|
- CA certificate must have `X509v3 Basic Constraints: CA:TRUE`
|
||||||
|
- CA certificate must have `X509v3 Key Usage: Certificate Sign`
|
||||||
|
- Key format: RSA, ECDSA, or PKCS#8
|
||||||
|
- Paths: must be absolute paths to mounted files
|
||||||
|
|
||||||
|
## Creating a Certificate
|
||||||
|
|
||||||
|
Once the stack is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create a certificate profile in certctl (defines allowed key types, TTL, etc.)
|
||||||
|
curl -X POST http://localhost:8443/api/v1/profiles \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"id": "prof-internal",
|
||||||
|
"name": "Internal Services",
|
||||||
|
"description": "For internal APIs and web apps",
|
||||||
|
"max_ttl_hours": 8760,
|
||||||
|
"key_types": ["rsa-2048", "ecdsa-p256"]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 2. Create a renewal policy (defines issuer, renewal thresholds, etc.)
|
||||||
|
curl -X POST http://localhost:8443/api/v1/policies \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"id": "pol-internal",
|
||||||
|
"name": "Internal Renewal Policy",
|
||||||
|
"issuer_id": "iss-local",
|
||||||
|
"profile_id": "prof-internal",
|
||||||
|
"renewal_threshold_days": 30,
|
||||||
|
"alert_thresholds_days": [30, 14, 7, 0]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 3. Create a certificate (triggers issuance immediately)
|
||||||
|
curl -X POST http://localhost:8443/api/v1/certificates \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"common_name": "api.internal.local",
|
||||||
|
"sans": ["app.internal.local", "www.internal.local"],
|
||||||
|
"policy_id": "pol-internal"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 4. Create a Traefik target (agent will deploy to this)
|
||||||
|
curl -X POST http://localhost:8443/api/v1/targets \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"id": "target-traefik-01",
|
||||||
|
"name": "Traefik Primary",
|
||||||
|
"type": "traefik",
|
||||||
|
"config": {
|
||||||
|
"cert_dir": "/etc/traefik/certs"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 5. Create a deployment job (agent picks this up and deploys)
|
||||||
|
curl -X POST http://localhost:8443/api/v1/certificates/{cert-id}/deploy \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"target_ids": ["target-traefik-01"]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Once deployed, Traefik automatically loads the new certificate from the certs directory.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Certificate Lifecycle
|
||||||
|
|
||||||
|
1. **Issue** — certctl-server generates certificate from Local CA (self-signed or sub-CA)
|
||||||
|
2. **Store** — certificate stored in PostgreSQL with full audit trail
|
||||||
|
3. **Deploy** — certctl-agent writes `{cert-id}.crt` + `{cert-id}.key` to `/etc/traefik/certs`
|
||||||
|
4. **Reload** — Traefik file provider detects new files and hot-loads them (zero downtime)
|
||||||
|
5. **Monitor** — certctl tracks deployment status and renewal timelines
|
||||||
|
|
||||||
|
### Self-Signed CA
|
||||||
|
|
||||||
|
- Generated automatically on first startup if `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH` are not set
|
||||||
|
- Certificate stored in server's in-memory state (not persisted)
|
||||||
|
- All issued certs chain to this self-signed root
|
||||||
|
- Use this for: demos, development, internal labs
|
||||||
|
|
||||||
|
### Sub-CA Mode
|
||||||
|
|
||||||
|
- Requires you to provide an existing CA certificate and key
|
||||||
|
- Issued certificates chain to your enterprise root CA
|
||||||
|
- All issued certs are trustworthy to systems with your root CA in their trust store
|
||||||
|
- Use this for: production internal services, compliance requirements, enterprise PKI
|
||||||
|
|
||||||
|
## File Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
private-ca-traefik/
|
||||||
|
├── docker-compose.yml # Stack definition
|
||||||
|
├── traefik-config/ # Traefik dynamic config (you create)
|
||||||
|
│ └── default.yaml # Routing rules and TLS settings
|
||||||
|
├── ca-certs/ # CA certificate and key (for sub-CA mode)
|
||||||
|
│ ├── ca-cert.pem # Your enterprise CA certificate
|
||||||
|
│ └── ca-key.pem # Your enterprise CA private key
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### certctl Dashboard
|
||||||
|
The server provides a REST API on port 8443. Example queries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all certificates
|
||||||
|
curl http://localhost:8443/api/v1/certificates
|
||||||
|
|
||||||
|
# Check certificate status
|
||||||
|
curl http://localhost:8443/api/v1/certificates/{cert-id}
|
||||||
|
|
||||||
|
# View audit trail
|
||||||
|
curl http://localhost:8443/api/v1/audit
|
||||||
|
|
||||||
|
# Check renewal policy compliance
|
||||||
|
curl http://localhost:8443/api/v1/policies/{policy-id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Traefik Dashboard
|
||||||
|
http://localhost:8080 shows:
|
||||||
|
- HTTP routers and services
|
||||||
|
- TLS certificates currently loaded
|
||||||
|
- Request/response metrics
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
```bash
|
||||||
|
# certctl server logs
|
||||||
|
docker compose logs certctl-server
|
||||||
|
|
||||||
|
# certctl agent logs
|
||||||
|
docker compose logs certctl-agent
|
||||||
|
|
||||||
|
# Traefik logs
|
||||||
|
docker compose logs traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customizing Traefik Config
|
||||||
|
|
||||||
|
Edit `traefik-config/default.yaml` to add routers for your services:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
# Internal API
|
||||||
|
api:
|
||||||
|
rule: "Host(`api.internal.local`)"
|
||||||
|
service: api-service
|
||||||
|
tls: {}
|
||||||
|
|
||||||
|
# Web application
|
||||||
|
webapp:
|
||||||
|
rule: "Host(`app.internal.local`)"
|
||||||
|
service: webapp-service
|
||||||
|
tls: {}
|
||||||
|
|
||||||
|
services:
|
||||||
|
api-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://api-backend:3000"
|
||||||
|
|
||||||
|
webapp-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://webapp-backend:3001"
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes are picked up automatically (file watcher enabled).
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
1. **Use sub-CA mode** — chain to your enterprise root for full trust
|
||||||
|
2. **Enable API key authentication** — set `CERTCTL_AUTH_TYPE: api-key` and `CERTCTL_API_KEY`
|
||||||
|
3. **Use agent-side key generation** — set `CERTCTL_KEYGEN_MODE: agent` (keys never leave agents)
|
||||||
|
4. **Back up PostgreSQL** — certificate data is authoritative; database loss means certificate loss
|
||||||
|
5. **Monitor renewal windows** — set up alerts on policy thresholds
|
||||||
|
6. **Rotate CA keys regularly** — plan for future CA refresh (sub-CA mode)
|
||||||
|
7. **Audit certificate usage** — review `certctl_audit_events` for compliance
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Certificates not deploying
|
||||||
|
```bash
|
||||||
|
# Check agent is healthy
|
||||||
|
docker compose logs certctl-agent | grep heartbeat
|
||||||
|
|
||||||
|
# Check deployment job status
|
||||||
|
curl http://localhost:8443/api/v1/jobs | jq '.[] | select(.type == "Deployment")'
|
||||||
|
|
||||||
|
# Check Traefik is watching the directory
|
||||||
|
docker compose exec traefik ls -la /etc/traefik/certs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Traefik not reloading certs
|
||||||
|
```bash
|
||||||
|
# Verify file provider is enabled (check docker-compose.yml command)
|
||||||
|
# Verify certs volume is mounted at /etc/traefik/certs
|
||||||
|
# Check Traefik logs
|
||||||
|
docker compose logs traefik | grep "file"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CA cert not loading in sub-CA mode
|
||||||
|
```bash
|
||||||
|
# Verify file permissions
|
||||||
|
docker compose exec certctl-server ls -la /etc/certctl/
|
||||||
|
|
||||||
|
# Check server logs for CA loading errors
|
||||||
|
docker compose logs certctl-server | grep -i "ca\|cert"
|
||||||
|
|
||||||
|
# Verify CA certificate format
|
||||||
|
openssl x509 -in ca-certs/ca-cert.pem -text -noout | grep -A 3 "Basic Constraints"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop all services
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Remove all data (certificates, database, etc.)
|
||||||
|
docker compose down -v
|
||||||
|
|
||||||
|
# Remove CA cert files (if using custom CA)
|
||||||
|
rm -rf ca-certs/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Add more services** — create additional routers and backends in `traefik-config/default.yaml`
|
||||||
|
2. **Set up renewal automation** — configure renewal policies with thresholds
|
||||||
|
3. **Integrate with monitoring** — expose certctl metrics to Prometheus
|
||||||
|
4. **Enable notifications** — configure email/Slack alerts on certificate events
|
||||||
|
5. **Scale to multiple environments** — deploy separate certctl stacks per environment (dev/staging/prod)
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [certctl Architecture](../../docs/architecture.md)
|
||||||
|
- [Traefik File Provider](https://doc.traefik.io/traefik/providers/file/)
|
||||||
|
- [Local CA Sub-CA Mode](../../docs/connectors.md#local-ca)
|
||||||
|
- [Certificate Profiles](../../docs/quickstart.md#profiles)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL database for certctl
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: certctl-postgres-stepca-haproxy
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: certctl
|
||||||
|
POSTGRES_USER: certctl
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-certctl-dev-password}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U certctl -d certctl']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Smallstep step-ca (internal private CA)
|
||||||
|
# Initialized with default admin token and provisioner configuration
|
||||||
|
step-ca:
|
||||||
|
image: smallstep/step-ca:latest
|
||||||
|
container_name: step-ca-stepca-haproxy
|
||||||
|
environment:
|
||||||
|
# step-ca root password (for key encryption)
|
||||||
|
STEPPATH: /home/step/step-ca
|
||||||
|
# Provisioner password will be set up below
|
||||||
|
volumes:
|
||||||
|
# Persist step-ca configuration and keys
|
||||||
|
- step_ca_data:/home/step/step-ca
|
||||||
|
- ./step-ca-init.sh:/opt/step-ca-init.sh:ro
|
||||||
|
entrypoint: /bin/sh
|
||||||
|
command:
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
# Initialize step-ca if not already done
|
||||||
|
if [ ! -f /home/step/step-ca/config/ca.json ]; then
|
||||||
|
echo "Initializing step-ca..."
|
||||||
|
step ca init \
|
||||||
|
--name="certctl-demo-ca" \
|
||||||
|
--dns=step-ca \
|
||||||
|
--address=0.0.0.0:9000 \
|
||||||
|
--provisioner=admin \
|
||||||
|
--provisioner-password-file=<(echo "${STEP_CA_PASSWORD:-stepca-demo-password}") \
|
||||||
|
--password-file=<(echo "${STEP_CA_PASSWORD:-stepca-demo-password}") \
|
||||||
|
--deployment-type=standalone \
|
||||||
|
--acme 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add a JWK provisioner for certctl if not present
|
||||||
|
if ! step ca provisioner list 2>/dev/null | grep -q "certctl"; then
|
||||||
|
echo "Adding certctl JWK provisioner..."
|
||||||
|
step ca provisioner add certctl \
|
||||||
|
--type=JWK \
|
||||||
|
--password-file=<(echo "${STEP_CA_PROVISIONER_PASSWORD:-certctl-provisioner-demo}") \
|
||||||
|
2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start step-ca
|
||||||
|
echo "Starting step-ca..."
|
||||||
|
step-ca /home/step/step-ca/config/ca.json \
|
||||||
|
--password-file=<(echo "${STEP_CA_PASSWORD:-stepca-demo-password}")
|
||||||
|
ports:
|
||||||
|
- '9000:9000'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'step ca health --insecure || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# certctl server (control plane)
|
||||||
|
certctl-server:
|
||||||
|
image: ghcr.io/shankar0123/certctl-server:latest
|
||||||
|
container_name: certctl-server-stepca-haproxy
|
||||||
|
environment:
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: postgres://certctl:${DB_PASSWORD:-certctl-dev-password}@postgres:5432/certctl?sslmode=disable
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
CERTCTL_SERVER_PORT: 8443
|
||||||
|
CERTCTL_SERVER_HOST: 0.0.0.0
|
||||||
|
|
||||||
|
# Auth (disabled for demo; production should use API keys)
|
||||||
|
CERTCTL_AUTH_TYPE: none
|
||||||
|
|
||||||
|
# CORS (allow agent communication)
|
||||||
|
CERTCTL_CORS_ORIGINS: '*'
|
||||||
|
|
||||||
|
# Key generation mode (agent-side in production, server-side for demo)
|
||||||
|
CERTCTL_KEYGEN_MODE: agent
|
||||||
|
|
||||||
|
# step-ca issuer configuration
|
||||||
|
# step-ca runs on step-ca:9000 in this compose network
|
||||||
|
CERTCTL_STEPCA_URL: https://step-ca:9000
|
||||||
|
CERTCTL_STEPCA_ROOT_CERT_PATH: /etc/certctl/step-ca-root.crt
|
||||||
|
CERTCTL_STEPCA_PROVISIONER: certctl
|
||||||
|
CERTCTL_STEPCA_KEY_PATH: /etc/certctl/step-ca-provisioner.json
|
||||||
|
CERTCTL_STEPCA_PASSWORD: ${STEP_CA_PROVISIONER_PASSWORD:-certctl-provisioner-demo}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
CERTCTL_LOG_LEVEL: info
|
||||||
|
volumes:
|
||||||
|
# Mount step-ca certs for TLS verification (auto-generated by step-ca init)
|
||||||
|
- step_ca_data:/home/step/step-ca/config:ro
|
||||||
|
ports:
|
||||||
|
- '${SERVER_PORT:-8443}:8443'
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
step-ca:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/api/v1/health || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# certctl agent (runs on the target machine with HAProxy)
|
||||||
|
certctl-agent:
|
||||||
|
image: ghcr.io/shankar0123/certctl-agent:latest
|
||||||
|
container_name: certctl-agent-stepca-haproxy
|
||||||
|
environment:
|
||||||
|
# Control plane connection
|
||||||
|
CERTCTL_SERVER_URL: http://certctl-server:8443
|
||||||
|
CERTCTL_API_KEY: ${AGENT_API_KEY:-agent-demo-key}
|
||||||
|
|
||||||
|
# Key generation (agent-side keys, never sent to server)
|
||||||
|
CERTCTL_KEYGEN_MODE: agent
|
||||||
|
CERTCTL_KEY_DIR: /var/lib/certctl/keys
|
||||||
|
|
||||||
|
# Discovery (scan existing certs so operator knows what's already deployed)
|
||||||
|
CERTCTL_DISCOVERY_DIRS: /etc/haproxy/ssl
|
||||||
|
|
||||||
|
# Heartbeat interval
|
||||||
|
CERTCTL_HEARTBEAT_INTERVAL: 30s
|
||||||
|
|
||||||
|
# Agent metadata (self-reported)
|
||||||
|
CERTCTL_AGENT_NAME: haproxy-agent-01
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
CERTCTL_LOG_LEVEL: info
|
||||||
|
volumes:
|
||||||
|
# Mount HAProxy config and cert directories
|
||||||
|
# In production, these would be the actual HAProxy paths
|
||||||
|
- haproxy_certs:/etc/haproxy/ssl
|
||||||
|
- haproxy_conf:/etc/haproxy
|
||||||
|
# Agent key storage (persisted across restarts)
|
||||||
|
- agent_keys:/var/lib/certctl/keys
|
||||||
|
depends_on:
|
||||||
|
certctl-server:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# HAProxy reverse proxy / load balancer
|
||||||
|
# This is where certificates will be deployed
|
||||||
|
haproxy:
|
||||||
|
image: haproxy:2.9-alpine
|
||||||
|
container_name: certctl-haproxy-stepca-haproxy
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
- '443:443'
|
||||||
|
volumes:
|
||||||
|
- haproxy_conf:/etc/haproxy
|
||||||
|
- haproxy_certs:/etc/haproxy/ssl
|
||||||
|
# Default HAProxy config
|
||||||
|
- ./haproxy.cfg:/etc/haproxy/haproxy.cfg:ro
|
||||||
|
depends_on:
|
||||||
|
- certctl-agent
|
||||||
|
networks:
|
||||||
|
- certctl-network
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:8080/stats || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
certctl-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
step_ca_data:
|
||||||
|
driver: local
|
||||||
|
haproxy_certs:
|
||||||
|
driver: local
|
||||||
|
haproxy_conf:
|
||||||
|
driver: local
|
||||||
|
agent_keys:
|
||||||
|
driver: local
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
global
|
||||||
|
log stdout local0
|
||||||
|
log stdout local1 notice
|
||||||
|
chroot /var/lib/haproxy
|
||||||
|
stats socket /run/haproxy/admin.sock mode 660 level admin
|
||||||
|
stats timeout 30s
|
||||||
|
user haproxy
|
||||||
|
group haproxy
|
||||||
|
daemon
|
||||||
|
|
||||||
|
# Default SSL options for modern TLS
|
||||||
|
tune.ssl.default-dh-param 2048
|
||||||
|
ssl-default-bind-ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384
|
||||||
|
ssl-default-bind-options ssl-min-ver TLSv1.2
|
||||||
|
|
||||||
|
defaults
|
||||||
|
mode http
|
||||||
|
log global
|
||||||
|
option httplog
|
||||||
|
option dontlognull
|
||||||
|
timeout connect 5000
|
||||||
|
timeout client 50000
|
||||||
|
timeout server 50000
|
||||||
|
errorfile 400 /etc/haproxy/errors/400.http
|
||||||
|
errorfile 403 /etc/haproxy/errors/403.http
|
||||||
|
errorfile 408 /etc/haproxy/errors/408.http
|
||||||
|
errorfile 500 /etc/haproxy/errors/500.http
|
||||||
|
errorfile 502 /etc/haproxy/errors/502.http
|
||||||
|
errorfile 503 /etc/haproxy/errors/503.http
|
||||||
|
errorfile 504 /etc/haproxy/errors/504.http
|
||||||
|
|
||||||
|
# Statistics endpoint (accessible on port 8080)
|
||||||
|
listen stats
|
||||||
|
bind *:8080
|
||||||
|
stats enable
|
||||||
|
stats uri /stats
|
||||||
|
stats refresh 30s
|
||||||
|
stats admin if TRUE
|
||||||
|
|
||||||
|
# Example HTTPS frontend with certificate from certctl
|
||||||
|
# This frontend will serve HTTPS on port 443 using a combined PEM file
|
||||||
|
# deployed by certctl to /etc/haproxy/ssl/cert.pem
|
||||||
|
frontend https_in
|
||||||
|
# HTTP redirect to HTTPS
|
||||||
|
bind *:80
|
||||||
|
mode http
|
||||||
|
acl is_http hdr(X-Forwarded-Proto) http
|
||||||
|
redirect scheme https code 301 if !is_https
|
||||||
|
|
||||||
|
# HTTPS with certificate
|
||||||
|
# In production, certctl will manage cert.pem and reload HAProxy after deployment
|
||||||
|
bind *:443 ssl crt /etc/haproxy/ssl/cert.pem strict-sni
|
||||||
|
mode http
|
||||||
|
option httplog
|
||||||
|
|
||||||
|
# Default backend
|
||||||
|
default_backend http_backend
|
||||||
|
|
||||||
|
# Example backend (simple web service placeholder)
|
||||||
|
backend http_backend
|
||||||
|
mode http
|
||||||
|
option httpchk GET /
|
||||||
|
server local_app 127.0.0.1:8000 check disabled
|
||||||
|
|
||||||
|
# Health endpoint (useful for certctl agent deployment verification)
|
||||||
|
frontend health
|
||||||
|
bind *:9999
|
||||||
|
mode http
|
||||||
|
monitor-uri /health
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
# step-ca + HAProxy Example
|
||||||
|
|
||||||
|
This example demonstrates certctl managing certificates issued by **Smallstep step-ca** and deploying them to **HAProxy**.
|
||||||
|
|
||||||
|
## Scenario
|
||||||
|
|
||||||
|
You're a Smallstep user running step-ca as your internal PKI. You have HAProxy load balancers that need certificates. This setup:
|
||||||
|
|
||||||
|
1. **step-ca** issues certificates (via JWK provisioner, no challenge solving)
|
||||||
|
2. **certctl** manages the certificate lifecycle (renewal policies, deployment, audit)
|
||||||
|
3. **HAProxy** serves HTTPS with certificates managed by certctl
|
||||||
|
|
||||||
|
This is the natural choice if you're already invested in step-ca and want to consolidate certificate lifecycle management without learning Let's Encrypt, DNS-01 challenges, or external integrations.
|
||||||
|
|
||||||
|
## What's Included
|
||||||
|
|
||||||
|
| Service | Image | Purpose |
|
||||||
|
|---------|-------|---------|
|
||||||
|
| **step-ca** | `smallstep/step-ca:latest` | Private internal CA |
|
||||||
|
| **certctl-server** | `ghcr.io/shankar0123/certctl-server:latest` | Certificate management control plane |
|
||||||
|
| **certctl-agent** | `ghcr.io/shankar0123/certctl-agent:latest` | Agent running on HAProxy server |
|
||||||
|
| **haproxy** | `haproxy:2.9-alpine` | Reverse proxy / load balancer |
|
||||||
|
| **postgres** | `postgres:16-alpine` | certctl audit trail + config storage |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- Curl (to interact with APIs)
|
||||||
|
|
||||||
|
### 1. Start Everything
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Initialize step-ca with a self-signed root CA
|
||||||
|
- Create a JWK provisioner named `certctl` (pre-configured credentials)
|
||||||
|
- Start certctl-server (connected to step-ca)
|
||||||
|
- Start the certctl-agent (ready to deploy certs to HAProxy)
|
||||||
|
- Start HAProxy with a placeholder config
|
||||||
|
|
||||||
|
Monitor logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f certctl-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait for all services to reach healthy state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
NAME STATUS
|
||||||
|
certctl-postgres-... healthy
|
||||||
|
certctl-server-... healthy
|
||||||
|
step-ca-... healthy
|
||||||
|
certctl-agent-... running
|
||||||
|
certctl-haproxy-... healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Access certctl Dashboard
|
||||||
|
|
||||||
|
Open your browser to:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:8443
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see an empty dashboard. This is expected — no certificates issued yet.
|
||||||
|
|
||||||
|
### 3. Create a Certificate Profile
|
||||||
|
|
||||||
|
This defines what certificates certctl can issue (key algorithm, max TTL, allowed names).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8443/api/v1/profiles \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"name": "internal-web",
|
||||||
|
"key_type": "rsa-2048",
|
||||||
|
"max_ttl_days": 90,
|
||||||
|
"description": "Internal web services"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create an HAProxy Deployment Target
|
||||||
|
|
||||||
|
This tells certctl where to deploy certificates on the HAProxy server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8443/api/v1/targets \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"name": "haproxy-01",
|
||||||
|
"type": "haproxy",
|
||||||
|
"enabled": true,
|
||||||
|
"config": {
|
||||||
|
"pem_path": "/etc/haproxy/ssl/cert.pem",
|
||||||
|
"reload_command": "systemctl reload haproxy",
|
||||||
|
"validate_command": "haproxy -c -f /etc/haproxy/haproxy.cfg"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: In the Docker Compose environment, reload command can be `kill -HUP $(pidof haproxy)` instead of `systemctl reload haproxy`.
|
||||||
|
|
||||||
|
### 5. Create a Renewal Policy
|
||||||
|
|
||||||
|
This ties a certificate profile to a deployment target and sets renewal thresholds.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8443/api/v1/renewal-policies \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"name": "haproxy-internal-web",
|
||||||
|
"profile_id": "<profile_id_from_step_3>",
|
||||||
|
"issuer_id": "iss-stepca",
|
||||||
|
"enabled": true,
|
||||||
|
"renewal_days_before_expiry": 30,
|
||||||
|
"alert_thresholds_days": [30, 14, 7, 0]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Get the issuer ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8443/api/v1/issuers | jq '.'
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `iss-stepca` in the list.
|
||||||
|
|
||||||
|
### 6. Issue a Certificate
|
||||||
|
|
||||||
|
Request a certificate via the API. The server will sign it via step-ca.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8443/api/v1/certificates \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"common_name": "api.internal.example.com",
|
||||||
|
"sans": ["api.internal.example.com", "api.staging.example.com"],
|
||||||
|
"issuer_id": "iss-stepca",
|
||||||
|
"profile_id": "<profile_id_from_step_3>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Deploy to HAProxy
|
||||||
|
|
||||||
|
Get the certificate ID and trigger deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8443/api/v1/certificates/<cert_id>/deploy \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"target_id": "<target_id_from_step_4>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent will:
|
||||||
|
1. Fetch the deployment job
|
||||||
|
2. Generate a combined PEM (cert + chain + key) locally
|
||||||
|
3. Write it to `/etc/haproxy/ssl/cert.pem` on HAProxy
|
||||||
|
4. Reload HAProxy
|
||||||
|
5. Report status back to certctl
|
||||||
|
|
||||||
|
### 8. Verify in Dashboard
|
||||||
|
|
||||||
|
Refresh http://localhost:8443 and you should see:
|
||||||
|
- 1 certificate (status: Active, expiry in 90 days)
|
||||||
|
- 1 deployment job (status: Completed)
|
||||||
|
- 1 agent (heartbeat: recent)
|
||||||
|
|
||||||
|
## Configuration Details
|
||||||
|
|
||||||
|
### step-ca Integration
|
||||||
|
|
||||||
|
step-ca is configured with:
|
||||||
|
|
||||||
|
- **Root CA Name**: `certctl-demo-ca`
|
||||||
|
- **Provisioner**: `certctl` (JWK type)
|
||||||
|
- **Default Password**: `certctl-provisioner-demo` (override with `STEP_CA_PROVISIONER_PASSWORD`)
|
||||||
|
|
||||||
|
To inspect step-ca:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec step-ca step ca provisioner list
|
||||||
|
docker compose exec step-ca step ca health --insecure
|
||||||
|
```
|
||||||
|
|
||||||
|
### HAProxy Combined PEM Format
|
||||||
|
|
||||||
|
HAProxy requires a single file with certificate, chain, and key concatenated:
|
||||||
|
|
||||||
|
```
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
[leaf certificate]
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
[intermediate CA]
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
[private key]
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent automatically constructs this file from the issued certificate and step-ca-provided chain.
|
||||||
|
|
||||||
|
**Security**: The combined PEM is written with `0600` permissions (owner-readable only) because it contains the private key.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Customize behavior with:
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `DB_PASSWORD` | `certctl-dev-password` | PostgreSQL password |
|
||||||
|
| `STEP_CA_PASSWORD` | `stepca-demo-password` | step-ca root key password |
|
||||||
|
| `STEP_CA_PROVISIONER_PASSWORD` | `certctl-provisioner-demo` | certctl JWK provisioner password |
|
||||||
|
| `AGENT_API_KEY` | `agent-demo-key` | Agent authentication token |
|
||||||
|
| `SERVER_PORT` | `8443` | certctl server external port |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
STEP_CA_PASSWORD=myca-password AGENT_API_KEY=secret-key docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integrating with an Existing step-ca Instance
|
||||||
|
|
||||||
|
If you already run step-ca elsewhere (not in this Compose file):
|
||||||
|
|
||||||
|
1. **Extract the root certificate** from your step-ca:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
step ca root /tmp/step-ca-root.crt --ca-url https://ca.internal:9000 --insecure
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create or retrieve the certctl JWK provisioner key**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
step ca provisioner list --ca-url https://ca.internal:9000 --insecure
|
||||||
|
step ca provisioner describe certctl --ca-url https://ca.internal:9000 --insecure
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update docker-compose.yml**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
certctl-server:
|
||||||
|
environment:
|
||||||
|
CERTCTL_STEPCA_URL: https://ca.internal:9000
|
||||||
|
CERTCTL_STEPCA_ROOT_CERT_PATH: /etc/certctl/step-ca-root.crt
|
||||||
|
CERTCTL_STEPCA_PROVISIONER_NAME: certctl
|
||||||
|
CERTCTL_STEPCA_PROVISIONER_KEY_PATH: /etc/certctl/step-ca-provisioner.json
|
||||||
|
CERTCTL_STEPCA_PROVISIONER_PASSWORD: <your-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Mount the cert and key**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /path/to/step-ca-root.crt:/etc/certctl/step-ca-root.crt:ro
|
||||||
|
- /path/to/provisioner.json:/etc/certctl/step-ca-provisioner.json:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes all containers and volumes (step-ca config, certificates, database).
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
- Replace image tags (`latest` → specific version)
|
||||||
|
- Use real TLS certificates for step-ca (self-signed is fine internally, but use proper roots for verification)
|
||||||
|
- Configure persistent storage for step-ca keys (HSM or encrypted filesystem)
|
||||||
|
- Set `CERTCTL_AUTH_TYPE: api-key` and rotate API keys regularly
|
||||||
|
- Enable audit trail export for compliance
|
||||||
|
- Configure renewal alerts (Slack, email, PagerDuty)
|
||||||
|
- Run agents on separate machines (not in Compose)
|
||||||
|
|
||||||
|
### Advanced Features
|
||||||
|
|
||||||
|
- **Multiple HAProxy instances**: Create additional targets and agents
|
||||||
|
- **Policy-based renewal**: Set different renewal windows per environment (staging vs. production)
|
||||||
|
- **Approval workflows**: Require manual approval before deploying to production
|
||||||
|
- **Discovery**: Scan existing HAProxy certs and bring them under management
|
||||||
|
- **Network scanning**: Discover TLS endpoints in your network and inventory them
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### step-ca fails to initialize
|
||||||
|
|
||||||
|
Check logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs step-ca
|
||||||
|
```
|
||||||
|
|
||||||
|
Common issues:
|
||||||
|
- Permissions on `/home/step/step-ca` volume
|
||||||
|
- Port 9000 already in use
|
||||||
|
|
||||||
|
### Agent can't reach server
|
||||||
|
|
||||||
|
Verify network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec certctl-agent curl http://certctl-server:8443/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### HAProxy config validation fails
|
||||||
|
|
||||||
|
Check HAProxy config syntax:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec haproxy haproxy -c -f /etc/haproxy/haproxy.cfg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment job stays in "Running" state
|
||||||
|
|
||||||
|
Check agent logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs certctl-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
- Agent can't write to `/etc/haproxy/ssl/cert.pem` (permissions)
|
||||||
|
- Reload command is misconfigured
|
||||||
|
- HAProxy container is not accessible
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [certctl Architecture](../../docs/architecture.md)
|
||||||
|
- [step-ca Connector Docs](../../docs/connectors.md#step-ca)
|
||||||
|
- [HAProxy Target Docs](../../docs/connectors.md#haproxy)
|
||||||
|
- [API Reference](../../api/openapi.yaml)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
|
||||||
|
1. Check the [troubleshooting guide](../../docs/troubleshooting.md)
|
||||||
|
2. Review service logs: `docker compose logs <service>`
|
||||||
|
3. Open an issue on GitHub
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# certctl Agent Install Script
|
||||||
|
# Detects OS (Linux/macOS) and architecture, downloads binary from GitHub Releases,
|
||||||
|
# installs to system path, configures service (systemd/launchd), and prompts for config.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
GITHUB_REPO="shankar0123/certctl"
|
||||||
|
RELEASE_URL="https://github.com/${GITHUB_REPO}/releases/latest/download"
|
||||||
|
INSTALL_DIR="/usr/local/bin"
|
||||||
|
SERVICE_NAME="certctl-agent"
|
||||||
|
|
||||||
|
# Detect OS and architecture
|
||||||
|
detect_platform() {
|
||||||
|
local os="$(uname -s)"
|
||||||
|
local arch="$(uname -m)"
|
||||||
|
|
||||||
|
case "$os" in
|
||||||
|
Linux*)
|
||||||
|
OS_TYPE="linux"
|
||||||
|
;;
|
||||||
|
Darwin*)
|
||||||
|
OS_TYPE="darwin"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Error: Unsupported OS: $os${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$arch" in
|
||||||
|
x86_64)
|
||||||
|
ARCH_TYPE="amd64"
|
||||||
|
;;
|
||||||
|
aarch64|arm64)
|
||||||
|
ARCH_TYPE="arm64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Error: Unsupported architecture: $arch${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print usage information
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [OPTIONS]
|
||||||
|
|
||||||
|
Install and configure the certctl agent on your system.
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
-h, --help Show this help message
|
||||||
|
--server-url URL Set CERTCTL_SERVER_URL (skips interactive prompt)
|
||||||
|
--api-key KEY Set CERTCTL_API_KEY (skips interactive prompt)
|
||||||
|
--no-start Install but don't start the service
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse command-line arguments
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--server-url)
|
||||||
|
SERVER_URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--api-key)
|
||||||
|
API_KEY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--no-start)
|
||||||
|
NO_START=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Error: Unknown option: $1${NC}"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if running as root/sudo on Linux
|
||||||
|
check_privileges() {
|
||||||
|
if [[ "$OS_TYPE" == "linux" && "$EUID" -ne 0 ]]; then
|
||||||
|
echo -e "${RED}Error: This script must be run as root on Linux. Try: sudo $0${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download agent binary from GitHub Releases
|
||||||
|
download_binary() {
|
||||||
|
local binary_name="certctl-agent-${OS_TYPE}-${ARCH_TYPE}"
|
||||||
|
local download_url="${RELEASE_URL}/${binary_name}"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Downloading certctl agent (${OS_TYPE}-${ARCH_TYPE})...${NC}"
|
||||||
|
|
||||||
|
if ! command -v curl &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: curl is required but not installed${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local temp_file=$(mktemp)
|
||||||
|
trap "rm -f $temp_file" EXIT
|
||||||
|
|
||||||
|
if ! curl -sSL -f "$download_url" -o "$temp_file"; then
|
||||||
|
echo -e "${RED}Error: Failed to download binary from $download_url${NC}"
|
||||||
|
echo "Make sure the latest release exists on GitHub with the binary asset for ${OS_TYPE}-${ARCH_TYPE}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$temp_file"
|
||||||
|
echo "$temp_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install binary to system path
|
||||||
|
install_binary() {
|
||||||
|
local binary_path="$1"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Installing to $INSTALL_DIR/$SERVICE_NAME...${NC}"
|
||||||
|
|
||||||
|
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||||
|
cp "$binary_path" "$INSTALL_DIR/$SERVICE_NAME"
|
||||||
|
else
|
||||||
|
# macOS: use sudo if not already running as root
|
||||||
|
if [[ "$EUID" -ne 0 ]]; then
|
||||||
|
sudo cp "$binary_path" "$INSTALL_DIR/$SERVICE_NAME"
|
||||||
|
else
|
||||||
|
cp "$binary_path" "$INSTALL_DIR/$SERVICE_NAME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$INSTALL_DIR/$SERVICE_NAME"
|
||||||
|
echo -e "${GREEN}Binary installed: $INSTALL_DIR/$SERVICE_NAME${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prompt for configuration (unless --server-url and --api-key provided)
|
||||||
|
prompt_for_config() {
|
||||||
|
if [[ -z "${SERVER_URL:-}" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Enter certctl server URL (e.g., https://certctl.example.com):${NC}"
|
||||||
|
read -r SERVER_URL
|
||||||
|
if [[ -z "$SERVER_URL" ]]; then
|
||||||
|
echo -e "${RED}Error: Server URL is required${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${API_KEY:-}" ]]; then
|
||||||
|
echo -e "${YELLOW}Enter certctl API key:${NC}"
|
||||||
|
read -sr API_KEY
|
||||||
|
echo ""
|
||||||
|
if [[ -z "$API_KEY" ]]; then
|
||||||
|
echo -e "${RED}Error: API key is required${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${AGENT_ID:-}" ]]; then
|
||||||
|
local default_agent_id="$(hostname)"
|
||||||
|
echo -e "${YELLOW}Enter agent ID (default: $default_agent_id):${NC}"
|
||||||
|
read -r AGENT_ID
|
||||||
|
if [[ -z "$AGENT_ID" ]]; then
|
||||||
|
AGENT_ID="$default_agent_id"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create configuration directory and env file (Linux)
|
||||||
|
setup_linux_config() {
|
||||||
|
local config_dir="/etc/certctl"
|
||||||
|
local config_file="$config_dir/agent.env"
|
||||||
|
local key_dir="/var/lib/certctl/keys"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Creating configuration directory...${NC}"
|
||||||
|
|
||||||
|
# Create /etc/certctl with restrictive permissions
|
||||||
|
mkdir -p "$config_dir"
|
||||||
|
chmod 755 "$config_dir"
|
||||||
|
|
||||||
|
# Create key storage directory with 0700 permissions
|
||||||
|
mkdir -p "$key_dir"
|
||||||
|
chmod 700 "$key_dir"
|
||||||
|
|
||||||
|
# Write agent configuration (overwrite if exists)
|
||||||
|
cat > "$config_file" <<EOF
|
||||||
|
# certctl Agent Configuration
|
||||||
|
# Generated by install-agent.sh on $(date)
|
||||||
|
|
||||||
|
# Agent ID (unique identifier in the fleet)
|
||||||
|
CERTCTL_AGENT_ID=$AGENT_ID
|
||||||
|
|
||||||
|
# Control plane server URL
|
||||||
|
CERTCTL_SERVER_URL=$SERVER_URL
|
||||||
|
|
||||||
|
# API authentication key
|
||||||
|
CERTCTL_API_KEY=$API_KEY
|
||||||
|
|
||||||
|
# Key generation mode (agent = agent-side keygen, server = server-side for demo only)
|
||||||
|
CERTCTL_KEYGEN_MODE=agent
|
||||||
|
|
||||||
|
# Key storage directory (agent-side keygen)
|
||||||
|
CERTCTL_KEY_DIR=$key_dir
|
||||||
|
|
||||||
|
# Logging level (debug, info, warn, error)
|
||||||
|
# CERTCTL_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Discovery directories (comma-separated paths to scan for existing certs)
|
||||||
|
# CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live,/etc/ssl/certs
|
||||||
|
|
||||||
|
# Enable deployment verification (TLS endpoint check post-deployment)
|
||||||
|
# CERTCTL_VERIFY_DEPLOYMENT=true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Restrict permissions on env file (contains API key)
|
||||||
|
chmod 600 "$config_file"
|
||||||
|
echo -e "${GREEN}Configuration written to: $config_file${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create configuration directory and env file (macOS)
|
||||||
|
setup_macos_config() {
|
||||||
|
local config_dir="$HOME/.certctl"
|
||||||
|
local config_file="$config_dir/agent.env"
|
||||||
|
local key_dir="$config_dir/keys"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Creating configuration directory...${NC}"
|
||||||
|
|
||||||
|
# Create ~/.certctl with restrictive permissions
|
||||||
|
mkdir -p "$config_dir"
|
||||||
|
chmod 700 "$config_dir"
|
||||||
|
|
||||||
|
# Create key storage directory
|
||||||
|
mkdir -p "$key_dir"
|
||||||
|
chmod 700 "$key_dir"
|
||||||
|
|
||||||
|
# Write agent configuration (overwrite if exists)
|
||||||
|
cat > "$config_file" <<EOF
|
||||||
|
# certctl Agent Configuration
|
||||||
|
# Generated by install-agent.sh on $(date)
|
||||||
|
|
||||||
|
# Agent ID (unique identifier in the fleet)
|
||||||
|
CERTCTL_AGENT_ID=$AGENT_ID
|
||||||
|
|
||||||
|
# Control plane server URL
|
||||||
|
CERTCTL_SERVER_URL=$SERVER_URL
|
||||||
|
|
||||||
|
# API authentication key
|
||||||
|
CERTCTL_API_KEY=$API_KEY
|
||||||
|
|
||||||
|
# Key generation mode (agent = agent-side keygen, server = server-side for demo only)
|
||||||
|
CERTCTL_KEYGEN_MODE=agent
|
||||||
|
|
||||||
|
# Key storage directory (agent-side keygen)
|
||||||
|
CERTCTL_KEY_DIR=$key_dir
|
||||||
|
|
||||||
|
# Logging level (debug, info, warn, error)
|
||||||
|
# CERTCTL_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Discovery directories (comma-separated paths to scan for existing certs)
|
||||||
|
# CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live,/etc/ssl/certs
|
||||||
|
|
||||||
|
# Enable deployment verification (TLS endpoint check post-deployment)
|
||||||
|
# CERTCTL_VERIFY_DEPLOYMENT=true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Restrict permissions on env file (contains API key)
|
||||||
|
chmod 600 "$config_file"
|
||||||
|
echo -e "${GREEN}Configuration written to: $config_file${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create and enable systemd service (Linux only)
|
||||||
|
setup_systemd_service() {
|
||||||
|
local service_file="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Creating systemd service file...${NC}"
|
||||||
|
|
||||||
|
cat > "$service_file" <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=certctl Agent - Certificate Lifecycle Management
|
||||||
|
Documentation=https://github.com/shankar0123/certctl
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10s
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# Load environment from /etc/certctl/agent.env
|
||||||
|
EnvironmentFile=/etc/certctl/agent.env
|
||||||
|
|
||||||
|
# Command to start the agent
|
||||||
|
ExecStart=/usr/local/bin/certctl-agent
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 644 "$service_file"
|
||||||
|
echo -e "${GREEN}Service file created: $service_file${NC}"
|
||||||
|
|
||||||
|
# Reload systemd daemon
|
||||||
|
systemctl daemon-reload
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create and enable launchd plist (macOS only)
|
||||||
|
setup_launchd_service() {
|
||||||
|
local plist_file="$HOME/Library/LaunchAgents/com.certctl.agent.plist"
|
||||||
|
local config_file="$HOME/.certctl/agent.env"
|
||||||
|
local launcher_script="$HOME/.certctl/launcher.sh"
|
||||||
|
local home_dir="$HOME"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Creating launchd service file...${NC}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$plist_file")"
|
||||||
|
|
||||||
|
# Create wrapper script that sources env file before executing agent
|
||||||
|
cat > "$launcher_script" <<'LAUNCHER_SCRIPT'
|
||||||
|
#!/bin/bash
|
||||||
|
set -a
|
||||||
|
source "$HOME/.certctl/agent.env"
|
||||||
|
set +a
|
||||||
|
exec /usr/local/bin/certctl-agent
|
||||||
|
LAUNCHER_SCRIPT
|
||||||
|
|
||||||
|
chmod 755 "$launcher_script"
|
||||||
|
|
||||||
|
# Create plist that references the launcher script
|
||||||
|
cat > "$plist_file" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.certctl.agent</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>$home_dir/.certctl/launcher.sh</string>
|
||||||
|
</array>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||||
|
<key>HOME</key>
|
||||||
|
<string>$home_dir</string>
|
||||||
|
</dict>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>$home_dir/.certctl/agent.log</string>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>$home_dir/.certctl/agent.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 644 "$plist_file"
|
||||||
|
echo -e "${GREEN}Service file created: $plist_file${NC}"
|
||||||
|
echo -e "${GREEN}Launcher script created: $launcher_script${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start the agent service
|
||||||
|
start_service() {
|
||||||
|
if [[ "${NO_START:-false}" == "true" ]]; then
|
||||||
|
echo -e "${YELLOW}Service not started (--no-start flag used)${NC}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Starting certctl agent service...${NC}"
|
||||||
|
|
||||||
|
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
systemctl start "$SERVICE_NAME"
|
||||||
|
sleep 2
|
||||||
|
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||||
|
echo -e "${GREEN}Service started successfully${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Warning: Service may not have started. Check logs with: systemctl status $SERVICE_NAME${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# macOS: load launchd service for current user
|
||||||
|
launchctl load "$HOME/Library/LaunchAgents/com.certctl.agent.plist" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
echo -e "${GREEN}Service loaded into launchd${NC}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print success message with next steps
|
||||||
|
print_summary() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN}certctl Agent Installation Complete${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Configuration:"
|
||||||
|
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||||
|
echo " Config file: /etc/certctl/agent.env"
|
||||||
|
echo " Key storage: /var/lib/certctl/keys"
|
||||||
|
echo " Service: /etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
echo " View logs: journalctl -u ${SERVICE_NAME} -f"
|
||||||
|
else
|
||||||
|
echo " Config file: $HOME/.certctl/agent.env"
|
||||||
|
echo " Key storage: $HOME/.certctl/keys"
|
||||||
|
echo " Service: $HOME/Library/LaunchAgents/com.certctl.agent.plist"
|
||||||
|
echo " View logs: tail -f $HOME/.certctl/agent.log"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Verify the service is running"
|
||||||
|
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||||
|
echo " systemctl status ${SERVICE_NAME}"
|
||||||
|
else
|
||||||
|
echo " launchctl list | grep certctl"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo " 2. Visit your certctl dashboard: $SERVER_URL"
|
||||||
|
echo " 3. The agent should appear in the fleet overview within 30 seconds"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main installation flow
|
||||||
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
detect_platform
|
||||||
|
check_privileges
|
||||||
|
|
||||||
|
echo -e "${GREEN}certctl Agent Installer${NC}"
|
||||||
|
echo "Detected platform: ${OS_TYPE}-${ARCH_TYPE}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
prompt_for_config
|
||||||
|
|
||||||
|
# Download and install binary
|
||||||
|
local binary_path
|
||||||
|
binary_path=$(download_binary)
|
||||||
|
install_binary "$binary_path"
|
||||||
|
|
||||||
|
# Setup OS-specific configuration
|
||||||
|
if [[ "$OS_TYPE" == "linux" ]]; then
|
||||||
|
setup_linux_config
|
||||||
|
setup_systemd_service
|
||||||
|
else
|
||||||
|
setup_macos_config
|
||||||
|
setup_launchd_service
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
start_service
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Comprehensive Referential Integrity Check for seed_demo.sql
|
||||||
|
-- Run AFTER migrations and seed data are loaded
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- 1. Verify certificate_versions.certificate_id references valid managed_certificates.id
|
||||||
|
SELECT 'FK VIOLATION: certificate_versions.certificate_id' AS issue, cv.id, cv.certificate_id
|
||||||
|
FROM certificate_versions cv
|
||||||
|
WHERE cv.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||||
|
ORDER BY cv.id;
|
||||||
|
|
||||||
|
-- 2. Verify certificate_target_mappings references valid IDs
|
||||||
|
SELECT 'FK VIOLATION: certificate_target_mappings.certificate_id' AS issue, ctm.certificate_id
|
||||||
|
FROM certificate_target_mappings ctm
|
||||||
|
WHERE ctm.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||||
|
ORDER BY ctm.certificate_id;
|
||||||
|
|
||||||
|
SELECT 'FK VIOLATION: certificate_target_mappings.target_id' AS issue, ctm.target_id
|
||||||
|
FROM certificate_target_mappings ctm
|
||||||
|
WHERE ctm.target_id NOT IN (SELECT id FROM deployment_targets)
|
||||||
|
ORDER BY ctm.target_id;
|
||||||
|
|
||||||
|
-- 3. Verify jobs references valid IDs
|
||||||
|
SELECT 'FK VIOLATION: jobs.certificate_id' AS issue, j.id, j.certificate_id
|
||||||
|
FROM jobs j
|
||||||
|
WHERE j.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||||
|
ORDER BY j.id;
|
||||||
|
|
||||||
|
SELECT 'FK VIOLATION: jobs.target_id' AS issue, j.id, j.target_id
|
||||||
|
FROM jobs j
|
||||||
|
WHERE j.target_id IS NOT NULL AND j.target_id NOT IN (SELECT id FROM deployment_targets)
|
||||||
|
ORDER BY j.id;
|
||||||
|
|
||||||
|
SELECT 'FK VIOLATION: jobs.agent_id' AS issue, j.id, j.agent_id
|
||||||
|
FROM jobs j
|
||||||
|
WHERE j.agent_id NOT IN (SELECT id FROM agents)
|
||||||
|
ORDER BY j.id;
|
||||||
|
|
||||||
|
-- 4. Verify discovered_certificates references valid IDs
|
||||||
|
SELECT 'FK VIOLATION: discovered_certificates.agent_id' AS issue, dc.id, dc.agent_id
|
||||||
|
FROM discovered_certificates dc
|
||||||
|
WHERE dc.agent_id NOT IN (SELECT id FROM agents)
|
||||||
|
ORDER BY dc.id;
|
||||||
|
|
||||||
|
SELECT 'FK VIOLATION: discovered_certificates.discovery_scan_id' AS issue, dc.id, dc.discovery_scan_id
|
||||||
|
FROM discovered_certificates dc
|
||||||
|
WHERE dc.discovery_scan_id IS NOT NULL AND dc.discovery_scan_id NOT IN (SELECT id FROM discovery_scans)
|
||||||
|
ORDER BY dc.id;
|
||||||
|
|
||||||
|
-- 5. Verify notification_events references valid certificate_id
|
||||||
|
SELECT 'FK VIOLATION: notification_events.certificate_id' AS issue, ne.id, ne.certificate_id
|
||||||
|
FROM notification_events ne
|
||||||
|
WHERE ne.certificate_id IS NOT NULL AND ne.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||||
|
ORDER BY ne.id;
|
||||||
|
|
||||||
|
-- 6. Verify policy_violations references valid certificate_id
|
||||||
|
SELECT 'FK VIOLATION: policy_violations.certificate_id' AS issue, pv.id, pv.certificate_id
|
||||||
|
FROM policy_violations pv
|
||||||
|
WHERE pv.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||||
|
ORDER BY pv.id;
|
||||||
|
|
||||||
|
-- 7. Verify certificate_revocations references valid IDs
|
||||||
|
SELECT 'FK VIOLATION: certificate_revocations.certificate_id' AS issue, cr.id, cr.certificate_id
|
||||||
|
FROM certificate_revocations cr
|
||||||
|
WHERE cr.certificate_id NOT IN (SELECT id FROM managed_certificates)
|
||||||
|
ORDER BY cr.id;
|
||||||
|
|
||||||
|
SELECT 'FK VIOLATION: certificate_revocations.issuer_id' AS issue, cr.id, cr.issuer_id
|
||||||
|
FROM certificate_revocations cr
|
||||||
|
WHERE cr.issuer_id NOT IN (SELECT id FROM issuers)
|
||||||
|
ORDER BY cr.id;
|
||||||
|
|
||||||
|
-- 8. Verify agent_group_members references valid IDs
|
||||||
|
SELECT 'FK VIOLATION: agent_group_members.agent_group_id' AS issue, agm.agent_group_id
|
||||||
|
FROM agent_group_members agm
|
||||||
|
WHERE agm.agent_group_id NOT IN (SELECT id FROM agent_groups)
|
||||||
|
ORDER BY agm.agent_group_id;
|
||||||
|
|
||||||
|
SELECT 'FK VIOLATION: agent_group_members.agent_id' AS issue, agm.agent_id
|
||||||
|
FROM agent_group_members agm
|
||||||
|
WHERE agm.agent_id NOT IN (SELECT id FROM agents)
|
||||||
|
ORDER BY agm.agent_id;
|
||||||
|
|
||||||
|
-- 9. Verify owners.team_id references valid teams.id
|
||||||
|
SELECT 'FK VIOLATION: owners.team_id' AS issue, o.id, o.team_id
|
||||||
|
FROM owners o
|
||||||
|
WHERE o.team_id IS NOT NULL AND o.team_id NOT IN (SELECT id FROM teams)
|
||||||
|
ORDER BY o.id;
|
||||||
|
|
||||||
|
-- 10. Verify deployment_targets.agent_id references valid agents.id
|
||||||
|
SELECT 'FK VIOLATION: deployment_targets.agent_id' AS issue, dt.id, dt.agent_id
|
||||||
|
FROM deployment_targets dt
|
||||||
|
WHERE dt.agent_id NOT IN (SELECT id FROM agents)
|
||||||
|
ORDER BY dt.id;
|
||||||
|
|
||||||
|
-- 11. Verify managed_certificates FK columns
|
||||||
|
SELECT 'FK VIOLATION: managed_certificates.owner_id' AS issue, mc.id, mc.owner_id
|
||||||
|
FROM managed_certificates mc
|
||||||
|
WHERE mc.owner_id IS NOT NULL AND mc.owner_id NOT IN (SELECT id FROM owners)
|
||||||
|
ORDER BY mc.id;
|
||||||
|
|
||||||
|
SELECT 'FK VIOLATION: managed_certificates.team_id' AS issue, mc.id, mc.team_id
|
||||||
|
FROM managed_certificates mc
|
||||||
|
WHERE mc.team_id IS NOT NULL AND mc.team_id NOT IN (SELECT id FROM teams)
|
||||||
|
ORDER BY mc.id;
|
||||||
|
|
||||||
|
SELECT 'FK VIOLATION: managed_certificates.issuer_id' AS issue, mc.id, mc.issuer_id
|
||||||
|
FROM managed_certificates mc
|
||||||
|
WHERE mc.issuer_id NOT IN (SELECT id FROM issuers)
|
||||||
|
ORDER BY mc.id;
|
||||||
|
|
||||||
|
SELECT 'FK VIOLATION: managed_certificates.renewal_policy_id' AS issue, mc.id, mc.renewal_policy_id
|
||||||
|
FROM managed_certificates mc
|
||||||
|
WHERE mc.renewal_policy_id IS NOT NULL AND mc.renewal_policy_id NOT IN (SELECT id FROM renewal_policies)
|
||||||
|
ORDER BY mc.id;
|
||||||
|
|
||||||
|
-- 12. Check for duplicate primary keys
|
||||||
|
SELECT 'DUPLICATE PK: teams' AS issue, id, COUNT(*) as count
|
||||||
|
FROM teams GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT 'DUPLICATE PK: owners' AS issue, id, COUNT(*) as count
|
||||||
|
FROM owners GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT 'DUPLICATE PK: agents' AS issue, id, COUNT(*) as count
|
||||||
|
FROM agents GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT 'DUPLICATE PK: deployment_targets' AS issue, id, COUNT(*) as count
|
||||||
|
FROM deployment_targets GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT 'DUPLICATE PK: managed_certificates' AS issue, id, COUNT(*) as count
|
||||||
|
FROM managed_certificates GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT 'DUPLICATE PK: certificate_versions' AS issue, id, COUNT(*) as count
|
||||||
|
FROM certificate_versions GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT 'DUPLICATE PK: issuers' AS issue, id, COUNT(*) as count
|
||||||
|
FROM issuers GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT 'DUPLICATE PK: renewal_policies' AS issue, id, COUNT(*) as count
|
||||||
|
FROM renewal_policies GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT 'DUPLICATE PK: jobs' AS issue, id, COUNT(*) as count
|
||||||
|
FROM jobs GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT 'DUPLICATE PK: certificate_profiles' AS issue, id, COUNT(*) as count
|
||||||
|
FROM certificate_profiles GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
SELECT 'DUPLICATE PK: certificate_revocations' AS issue, id, COUNT(*) as count
|
||||||
|
FROM certificate_revocations GROUP BY id HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
-- 13. Check fingerprint_sha256 uniqueness in certificate_versions
|
||||||
|
SELECT 'DUPLICATE FINGERPRINT: certificate_versions' AS issue, fingerprint_sha256, COUNT(*) as count
|
||||||
|
FROM certificate_versions
|
||||||
|
WHERE fingerprint_sha256 IS NOT NULL
|
||||||
|
GROUP BY fingerprint_sha256
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
-- 14. Check serial number uniqueness in certificate_versions
|
||||||
|
SELECT 'DUPLICATE SERIAL: certificate_versions' AS issue, serial_number, COUNT(*) as count
|
||||||
|
FROM certificate_versions
|
||||||
|
WHERE serial_number IS NOT NULL
|
||||||
|
GROUP BY serial_number
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
-- 15. Verify discovery_scan_id references are valid
|
||||||
|
SELECT 'FK VIOLATION: discovered_certificates.discovery_scan_id references' AS issue,
|
||||||
|
dc.id, dc.discovery_scan_id, ds.id
|
||||||
|
FROM discovered_certificates dc
|
||||||
|
LEFT JOIN discovery_scans ds ON dc.discovery_scan_id = ds.id
|
||||||
|
WHERE dc.discovery_scan_id IS NOT NULL AND ds.id IS NULL;
|
||||||
|
|
||||||
|
-- Summary: Count total records
|
||||||
|
SELECT 'SUMMARY: teams' AS table_name, COUNT(*) as count FROM teams UNION ALL
|
||||||
|
SELECT 'SUMMARY: owners', COUNT(*) FROM owners UNION ALL
|
||||||
|
SELECT 'SUMMARY: agents', COUNT(*) FROM agents UNION ALL
|
||||||
|
SELECT 'SUMMARY: deployment_targets', COUNT(*) FROM deployment_targets UNION ALL
|
||||||
|
SELECT 'SUMMARY: managed_certificates', COUNT(*) FROM managed_certificates UNION ALL
|
||||||
|
SELECT 'SUMMARY: certificate_versions', COUNT(*) FROM certificate_versions UNION ALL
|
||||||
|
SELECT 'SUMMARY: certificate_target_mappings', COUNT(*) FROM certificate_target_mappings UNION ALL
|
||||||
|
SELECT 'SUMMARY: issuers', COUNT(*) FROM issuers UNION ALL
|
||||||
|
SELECT 'SUMMARY: renewal_policies', COUNT(*) FROM renewal_policies UNION ALL
|
||||||
|
SELECT 'SUMMARY: jobs', COUNT(*) FROM jobs UNION ALL
|
||||||
|
SELECT 'SUMMARY: certificate_profiles', COUNT(*) FROM certificate_profiles UNION ALL
|
||||||
|
SELECT 'SUMMARY: certificate_revocations', COUNT(*) FROM certificate_revocations UNION ALL
|
||||||
|
SELECT 'SUMMARY: audit_events', COUNT(*) FROM audit_events UNION ALL
|
||||||
|
SELECT 'SUMMARY: discovery_scans', COUNT(*) FROM discovery_scans UNION ALL
|
||||||
|
SELECT 'SUMMARY: discovered_certificates', COUNT(*) FROM discovered_certificates;
|
||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -134,6 +135,11 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
created, err := h.svc.RegisterAgent(r.Context(), agent)
|
created, err := h.svc.RegisterAgent(r.Context(), agent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") || strings.Contains(errMsg, "already exists") {
|
||||||
|
ErrorWithRequestID(w, http.StatusConflict, "Agent with this name already exists", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -184,6 +190,11 @@ func (h AgentHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.Heartbeat(r.Context(), agentID, metadata); err != nil {
|
if err := h.svc.Heartbeat(r.Context(), agentID, metadata); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
ErrorWithRequestID(w, http.StatusNotFound, "Agent not found", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("Heartbeat failed", "agent_id", agentID, "error", err.Error())
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to record heartbeat", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,11 +353,12 @@ func TestCreateCertificate_Success(t *testing.T) {
|
|||||||
handler := NewCertificateHandler(mock)
|
handler := NewCertificateHandler(mock)
|
||||||
|
|
||||||
certBody := domain.ManagedCertificate{
|
certBody := domain.ManagedCertificate{
|
||||||
Name: "Production Cert",
|
Name: "Production Cert",
|
||||||
CommonName: "example.com",
|
CommonName: "example.com",
|
||||||
OwnerID: "o-alice",
|
OwnerID: "o-alice",
|
||||||
TeamID: "t-platform",
|
TeamID: "t-platform",
|
||||||
IssuerID: "iss-local",
|
IssuerID: "iss-local",
|
||||||
|
RenewalPolicyID: "rp-standard",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(certBody)
|
body, _ := json.Marshal(certBody)
|
||||||
|
|
||||||
@@ -410,11 +411,12 @@ func TestCreateCertificate_ServiceError(t *testing.T) {
|
|||||||
handler := NewCertificateHandler(mock)
|
handler := NewCertificateHandler(mock)
|
||||||
|
|
||||||
certBody := domain.ManagedCertificate{
|
certBody := domain.ManagedCertificate{
|
||||||
Name: "Production Cert",
|
Name: "Production Cert",
|
||||||
CommonName: "example.com",
|
CommonName: "example.com",
|
||||||
OwnerID: "o-alice",
|
OwnerID: "o-alice",
|
||||||
TeamID: "t-platform",
|
TeamID: "t-platform",
|
||||||
IssuerID: "iss-local",
|
IssuerID: "iss-local",
|
||||||
|
RenewalPolicyID: "rp-standard",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(certBody)
|
body, _ := json.Marshal(certBody)
|
||||||
|
|
||||||
@@ -534,8 +536,8 @@ func TestArchiveCertificate_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
handler.ArchiveCertificate(w, req)
|
handler.ArchiveCertificate(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusInternalServerError {
|
if w.Code != http.StatusNotFound {
|
||||||
t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code)
|
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -231,9 +232,18 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
|
|||||||
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := ValidateRequired("name", cert.Name); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateRequired("renewal_policy_id", cert.RenewalPolicyID); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
created, err := h.svc.CreateCertificate(cert)
|
created, err := h.svc.CreateCertificate(cert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("failed to create certificate", "error", err, "request_id", requestID, "common_name", cert.CommonName, "name", cert.Name)
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -287,6 +297,11 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
|
|||||||
|
|
||||||
updated, err := h.svc.UpdateCertificate(id, cert)
|
updated, err := h.svc.UpdateCertificate(id, cert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("UpdateCertificate failed", "cert_id", id, "error", err.Error())
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -311,6 +326,10 @@ func (h CertificateHandler) ArchiveCertificate(w http.ResponseWriter, r *http.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.ArchiveCertificate(id); err != nil {
|
if err := h.svc.ArchiveCertificate(id); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to archive certificate", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to archive certificate", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -353,7 +372,12 @@ func (h CertificateHandler) GetCertificateVersions(w http.ResponseWriter, r *htt
|
|||||||
|
|
||||||
versions, total, err := h.svc.GetCertificateVersions(certID, page, perPage)
|
versions, total, err := h.svc.GetCertificateVersions(certID, page, perPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("GetCertificateVersions failed", "cert_id", certID, "error", err.Error())
|
||||||
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to get certificate versions", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +411,19 @@ func (h CertificateHandler) TriggerRenewal(w http.ResponseWriter, r *http.Reques
|
|||||||
certID := parts[0]
|
certID := parts[0]
|
||||||
|
|
||||||
if err := h.svc.TriggerRenewal(certID); err != nil {
|
if err := h.svc.TriggerRenewal(certID); err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "not found") {
|
||||||
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(errMsg, "cannot renew") {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(errMsg, "already in progress") {
|
||||||
|
ErrorWithRequestID(w, http.StatusConflict, errMsg, requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger renewal", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to trigger renewal", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -480,7 +517,7 @@ func (h CertificateHandler) RevokeCertificate(w http.ResponseWriter, r *http.Req
|
|||||||
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
ErrorWithRequestID(w, http.StatusBadRequest, errMsg, requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") {
|
if strings.Contains(errMsg, "not found") || strings.Contains(errMsg, "failed to fetch") || strings.Contains(errMsg, "failed to get") {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) {
|
|||||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
slog.Error("ExportPEM failed", "cert_id", id, "error", err.Error())
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -96,6 +98,11 @@ func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) {
|
|||||||
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.Contains(err.Error(), "cannot be parsed") || strings.Contains(err.Error(), "no certificates found") {
|
||||||
|
ErrorWithRequestID(w, http.StatusUnprocessableEntity, "Certificate data cannot be parsed as X.509", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("ExportPKCS12 failed", "cert_id", id, "error", err.Error())
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/.
|
||||||
|
|||||||
+147
-2
@@ -24,6 +24,10 @@ type Config struct {
|
|||||||
NetworkScan NetworkScanConfig
|
NetworkScan NetworkScanConfig
|
||||||
EST ESTConfig
|
EST ESTConfig
|
||||||
Verification VerificationConfig
|
Verification VerificationConfig
|
||||||
|
ACME ACMEConfig
|
||||||
|
Vault VaultConfig
|
||||||
|
DigiCert DigiCertConfig
|
||||||
|
Digest DigestConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifierConfig contains configuration for notification connectors.
|
// NotifierConfig contains configuration for notification connectors.
|
||||||
@@ -64,6 +68,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 +143,75 @@ type StepCAConfig struct {
|
|||||||
ProvisionerPassword string
|
ProvisionerPassword string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VaultConfig contains HashiCorp Vault PKI issuer connector configuration.
|
||||||
|
type VaultConfig struct {
|
||||||
|
// Addr is the Vault server address (e.g., "https://vault.example.com:8200").
|
||||||
|
// Required for Vault PKI integration.
|
||||||
|
// Setting: CERTCTL_VAULT_ADDR environment variable.
|
||||||
|
Addr string
|
||||||
|
|
||||||
|
// Token is the Vault token for authentication.
|
||||||
|
// Required for Vault PKI integration.
|
||||||
|
// Setting: CERTCTL_VAULT_TOKEN environment variable.
|
||||||
|
Token string
|
||||||
|
|
||||||
|
// Mount is the PKI secrets engine mount path.
|
||||||
|
// Default: "pki".
|
||||||
|
// Setting: CERTCTL_VAULT_MOUNT environment variable.
|
||||||
|
Mount string
|
||||||
|
|
||||||
|
// Role is the PKI role name used for signing certificates.
|
||||||
|
// Required for Vault PKI integration.
|
||||||
|
// Setting: CERTCTL_VAULT_ROLE environment variable.
|
||||||
|
Role string
|
||||||
|
|
||||||
|
// TTL is the requested certificate time-to-live.
|
||||||
|
// Default: "8760h" (1 year).
|
||||||
|
// Setting: CERTCTL_VAULT_TTL environment variable.
|
||||||
|
TTL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DigiCertConfig contains DigiCert CertCentral issuer connector configuration.
|
||||||
|
type DigiCertConfig struct {
|
||||||
|
// APIKey is the CertCentral API key for authentication.
|
||||||
|
// Required for DigiCert integration.
|
||||||
|
// Setting: CERTCTL_DIGICERT_API_KEY environment variable.
|
||||||
|
APIKey string
|
||||||
|
|
||||||
|
// OrgID is the DigiCert organization ID for certificate orders.
|
||||||
|
// Required for DigiCert integration.
|
||||||
|
// Setting: CERTCTL_DIGICERT_ORG_ID environment variable.
|
||||||
|
OrgID string
|
||||||
|
|
||||||
|
// ProductType is the DigiCert product type for certificate orders.
|
||||||
|
// Default: "ssl_basic". Common values: "ssl_basic", "ssl_wildcard", "ssl_ev_basic".
|
||||||
|
// Setting: CERTCTL_DIGICERT_PRODUCT_TYPE environment variable.
|
||||||
|
ProductType string
|
||||||
|
|
||||||
|
// BaseURL is the DigiCert CertCentral API base URL.
|
||||||
|
// Default: "https://www.digicert.com/services/v2".
|
||||||
|
// Setting: CERTCTL_DIGICERT_BASE_URL environment variable.
|
||||||
|
BaseURL 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.
|
||||||
@@ -130,13 +231,17 @@ type ACMEConfig struct {
|
|||||||
|
|
||||||
// DNSPresentScript is the path to a shell script that creates DNS TXT records.
|
// DNSPresentScript is the path to a shell script that creates DNS TXT records.
|
||||||
// Required for dns-01 and dns-persist-01 challenge types.
|
// Required for dns-01 and dns-persist-01 challenge types.
|
||||||
// Script receives: DOMAIN_NAME, VALIDATION_TOKEN, RECORD_NAME as env vars.
|
// Script receives these environment variables:
|
||||||
|
// - CERTCTL_DNS_DOMAIN: domain being validated (e.g., "example.com")
|
||||||
|
// - CERTCTL_DNS_FQDN: full record name (e.g., "_acme-challenge.example.com" or "_validation-persist.example.com")
|
||||||
|
// - CERTCTL_DNS_VALUE: TXT record value (key authorization digest for dns-01, or issuer domain info for dns-persist-01)
|
||||||
|
// - CERTCTL_DNS_TOKEN: ACME challenge token
|
||||||
// Example: /opt/dns-scripts/add-record.sh
|
// Example: /opt/dns-scripts/add-record.sh
|
||||||
DNSPresentScript string
|
DNSPresentScript string
|
||||||
|
|
||||||
// DNSCleanUpScript is the path to a shell script that removes DNS TXT records.
|
// DNSCleanUpScript is the path to a shell script that removes DNS TXT records.
|
||||||
// Used only for dns-01 challenges to clean up temporary validation records.
|
// Used only for dns-01 challenges to clean up temporary validation records.
|
||||||
// Script receives: DOMAIN_NAME, RECORD_NAME as env vars.
|
// Script receives the same environment variables as DNSPresentScript.
|
||||||
// Leave empty if cleanup is not needed (e.g., dns-persist-01).
|
// Leave empty if cleanup is not needed (e.g., dns-persist-01).
|
||||||
DNSCleanUpScript string
|
DNSCleanUpScript string
|
||||||
|
|
||||||
@@ -144,6 +249,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 +461,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 +482,33 @@ 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),
|
||||||
},
|
},
|
||||||
|
Vault: VaultConfig{
|
||||||
|
Addr: getEnv("CERTCTL_VAULT_ADDR", ""),
|
||||||
|
Token: getEnv("CERTCTL_VAULT_TOKEN", ""),
|
||||||
|
Mount: getEnv("CERTCTL_VAULT_MOUNT", "pki"),
|
||||||
|
Role: getEnv("CERTCTL_VAULT_ROLE", ""),
|
||||||
|
TTL: getEnv("CERTCTL_VAULT_TTL", "8760h"),
|
||||||
|
},
|
||||||
|
DigiCert: DigiCertConfig{
|
||||||
|
APIKey: getEnv("CERTCTL_DIGICERT_API_KEY", ""),
|
||||||
|
OrgID: getEnv("CERTCTL_DIGICERT_ORG_ID", ""),
|
||||||
|
ProductType: getEnv("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"),
|
||||||
|
BaseURL: getEnv("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"),
|
||||||
|
},
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
// Package digicert implements the issuer.Connector interface for DigiCert CertCentral.
|
||||||
|
//
|
||||||
|
// DigiCert CertCentral is an enterprise certificate authority offering DV, OV, and EV
|
||||||
|
// certificates. Unlike synchronous issuers (Vault, step-ca), DigiCert uses an
|
||||||
|
// asynchronous order model: submit an order, receive an order ID, then poll for
|
||||||
|
// completion. OV/EV certificates require organization validation which may take hours
|
||||||
|
// or days; DV certificates may be issued immediately.
|
||||||
|
//
|
||||||
|
// This connector maps to certctl's existing job state machine:
|
||||||
|
// - IssueCertificate submits the order; if status is "issued", returns cert immediately.
|
||||||
|
// If status is "pending", returns OrderID with empty CertPEM — the job system polls
|
||||||
|
// via GetOrderStatus.
|
||||||
|
// - GetOrderStatus polls the order; when status becomes "issued", downloads and
|
||||||
|
// parses the PEM bundle.
|
||||||
|
//
|
||||||
|
// Authentication: API key via X-DC-DEVKEY header.
|
||||||
|
//
|
||||||
|
// DigiCert CertCentral API used:
|
||||||
|
//
|
||||||
|
// POST /order/certificate/{product_type} - Submit certificate order
|
||||||
|
// GET /order/certificate/{order_id} - Check order status
|
||||||
|
// GET /certificate/{certificate_id}/download/format/pem_all - Download cert bundle
|
||||||
|
// PUT /certificate/{certificate_id}/revoke - Revoke certificate
|
||||||
|
// GET /user/me - Validate API credentials
|
||||||
|
package digicert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the DigiCert CertCentral issuer connector configuration.
|
||||||
|
type Config struct {
|
||||||
|
// APIKey is the CertCentral API key for authentication.
|
||||||
|
// Required. Set via CERTCTL_DIGICERT_API_KEY environment variable.
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
|
||||||
|
// OrgID is the DigiCert organization ID for certificate orders.
|
||||||
|
// Required. Set via CERTCTL_DIGICERT_ORG_ID environment variable.
|
||||||
|
OrgID string `json:"org_id"`
|
||||||
|
|
||||||
|
// ProductType is the DigiCert product type for certificate orders.
|
||||||
|
// Default: "ssl_basic". Set via CERTCTL_DIGICERT_PRODUCT_TYPE environment variable.
|
||||||
|
// Common values: "ssl_basic", "ssl_wildcard", "ssl_ev_basic", "ssl_plus", "ssl_multi_domain".
|
||||||
|
ProductType string `json:"product_type"`
|
||||||
|
|
||||||
|
// BaseURL is the DigiCert CertCentral API base URL.
|
||||||
|
// Default: "https://www.digicert.com/services/v2".
|
||||||
|
// Set via CERTCTL_DIGICERT_BASE_URL environment variable.
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the issuer.Connector interface for DigiCert CertCentral.
|
||||||
|
type Connector struct {
|
||||||
|
config *Config
|
||||||
|
logger *slog.Logger
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new DigiCert CertCentral connector with the given configuration and logger.
|
||||||
|
func New(config *Config, logger *slog.Logger) *Connector {
|
||||||
|
if config != nil {
|
||||||
|
if config.ProductType == "" {
|
||||||
|
config.ProductType = "ssl_basic"
|
||||||
|
}
|
||||||
|
if config.BaseURL == "" {
|
||||||
|
config.BaseURL = "https://www.digicert.com/services/v2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Connector{
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderRequest is the JSON body for DigiCert certificate order submission.
|
||||||
|
type orderRequest struct {
|
||||||
|
Certificate orderCert `json:"certificate"`
|
||||||
|
Organization orderOrg `json:"organization"`
|
||||||
|
ValidityYears int `json:"validity_years"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderCert struct {
|
||||||
|
CommonName string `json:"common_name"`
|
||||||
|
CSR string `json:"csr"`
|
||||||
|
DNSNames []string `json:"dns_names,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderOrg struct {
|
||||||
|
ID json.Number `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderResponse is the JSON response from a certificate order submission.
|
||||||
|
type orderResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CertificateID int `json:"certificate_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderStatusResponse is the JSON response from an order status check.
|
||||||
|
type orderStatusResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Certificate struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
CommonName string `json:"common_name"`
|
||||||
|
} `json:"certificate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig checks that the DigiCert configuration is valid and API access works.
|
||||||
|
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("invalid DigiCert config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.APIKey == "" {
|
||||||
|
return fmt.Errorf("DigiCert api_key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.OrgID == "" {
|
||||||
|
return fmt.Errorf("DigiCert org_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ProductType == "" {
|
||||||
|
cfg.ProductType = "ssl_basic"
|
||||||
|
}
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
cfg.BaseURL = "https://www.digicert.com/services/v2"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test API access via /user/me
|
||||||
|
meURL := cfg.BaseURL + "/user/me"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create API test request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-DC-DEVKEY", cfg.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("DigiCert API not reachable at %s: %w", cfg.BaseURL, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return fmt.Errorf("DigiCert API key is invalid (status %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("DigiCert API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &cfg
|
||||||
|
c.logger.Info("DigiCert CertCentral configuration validated",
|
||||||
|
"base_url", cfg.BaseURL,
|
||||||
|
"product_type", cfg.ProductType)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueCertificate submits a certificate order to DigiCert CertCentral.
|
||||||
|
// If the certificate is issued immediately (DV certs), returns the cert.
|
||||||
|
// If pending (OV/EV certs), returns OrderID with empty CertPEM for polling.
|
||||||
|
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||||
|
c.logger.Info("processing DigiCert issuance request",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"san_count", len(request.SANs),
|
||||||
|
"product_type", c.config.ProductType)
|
||||||
|
|
||||||
|
orderReq := orderRequest{
|
||||||
|
Certificate: orderCert{
|
||||||
|
CommonName: request.CommonName,
|
||||||
|
CSR: request.CSRPEM,
|
||||||
|
DNSNames: request.SANs,
|
||||||
|
},
|
||||||
|
Organization: orderOrg{
|
||||||
|
ID: json.Number(c.config.OrgID),
|
||||||
|
},
|
||||||
|
ValidityYears: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(orderReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal order request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderURL := fmt.Sprintf("%s/order/certificate/%s", c.config.BaseURL, c.config.ProductType)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, orderURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create order request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("DigiCert order request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read order response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return nil, fmt.Errorf("DigiCert order returned status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderResp orderResponse
|
||||||
|
if err := json.Unmarshal(respBody, &orderResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderID := fmt.Sprintf("%d", orderResp.ID)
|
||||||
|
|
||||||
|
c.logger.Info("DigiCert order submitted",
|
||||||
|
"order_id", orderID,
|
||||||
|
"status", orderResp.Status)
|
||||||
|
|
||||||
|
// If issued immediately (DV certs), download the certificate
|
||||||
|
if orderResp.Status == "issued" && orderResp.CertificateID > 0 {
|
||||||
|
certPEM, chainPEM, serial, notBefore, notAfter, err := c.downloadCertificate(ctx, orderResp.CertificateID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to download certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("DigiCert certificate issued immediately",
|
||||||
|
"order_id", orderID,
|
||||||
|
"serial", serial)
|
||||||
|
|
||||||
|
return &issuer.IssuanceResult{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
ChainPEM: chainPEM,
|
||||||
|
Serial: serial,
|
||||||
|
NotBefore: notBefore,
|
||||||
|
NotAfter: notAfter,
|
||||||
|
OrderID: orderID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending — return OrderID for polling via GetOrderStatus
|
||||||
|
c.logger.Info("DigiCert order pending validation",
|
||||||
|
"order_id", orderID,
|
||||||
|
"status", orderResp.Status)
|
||||||
|
|
||||||
|
return &issuer.IssuanceResult{
|
||||||
|
OrderID: orderID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewCertificate renews a certificate by submitting a new order.
|
||||||
|
// DigiCert uses reissue for renewal, but for simplicity we submit a new order
|
||||||
|
// (reissue requires the original order ID which may not be available).
|
||||||
|
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||||
|
c.logger.Info("processing DigiCert renewal request",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"san_count", len(request.SANs))
|
||||||
|
|
||||||
|
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||||
|
CommonName: request.CommonName,
|
||||||
|
SANs: request.SANs,
|
||||||
|
CSRPEM: request.CSRPEM,
|
||||||
|
EKUs: request.EKUs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeCertificate revokes a certificate at DigiCert CertCentral.
|
||||||
|
// DigiCert revocation uses certificate_id, so we extract it from the serial
|
||||||
|
// by looking up the order. For simplicity, we use the serial as the cert ID
|
||||||
|
// (the caller should provide the DigiCert certificate ID).
|
||||||
|
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||||
|
c.logger.Info("processing DigiCert revocation request", "serial", request.Serial)
|
||||||
|
|
||||||
|
reason := "unspecified"
|
||||||
|
if request.Reason != nil {
|
||||||
|
reason = *request.Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeBody := map[string]interface{}{
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(revokeBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal revoke request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DigiCert uses certificate_id in the URL path for revocation
|
||||||
|
revokeURL := fmt.Sprintf("%s/certificate/%s/revoke", c.config.BaseURL, request.Serial)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create revoke request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("DigiCert revoke request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// DigiCert returns 204 No Content on successful revocation
|
||||||
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("DigiCert revoke returned status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("DigiCert certificate revoked", "serial", request.Serial, "reason", reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderStatus checks the status of a DigiCert certificate order.
|
||||||
|
// If the order is "issued", downloads the certificate and returns it.
|
||||||
|
// If still "pending", returns pending status for continued polling.
|
||||||
|
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||||
|
c.logger.Debug("checking DigiCert order status", "order_id", orderID)
|
||||||
|
|
||||||
|
statusURL := fmt.Sprintf("%s/order/certificate/%s", c.config.BaseURL, orderID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create status request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("DigiCert status request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read status response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("DigiCert order status returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusResp orderStatusResponse
|
||||||
|
if err := json.Unmarshal(respBody, &statusResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse status response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
switch statusResp.Status {
|
||||||
|
case "issued":
|
||||||
|
if statusResp.Certificate.ID == 0 {
|
||||||
|
return nil, fmt.Errorf("order is issued but certificate_id is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM, chainPEM, serial, notBefore, notAfter, err := c.downloadCertificate(ctx, statusResp.Certificate.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to download certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("DigiCert order completed",
|
||||||
|
"order_id", orderID,
|
||||||
|
"serial", serial)
|
||||||
|
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "completed",
|
||||||
|
CertPEM: &certPEM,
|
||||||
|
ChainPEM: &chainPEM,
|
||||||
|
Serial: &serial,
|
||||||
|
NotBefore: ¬Before,
|
||||||
|
NotAfter: ¬After,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "pending", "processing":
|
||||||
|
msg := fmt.Sprintf("order %s is %s", orderID, statusResp.Status)
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "pending",
|
||||||
|
Message: &msg,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "rejected", "denied":
|
||||||
|
msg := fmt.Sprintf("order %s was %s", orderID, statusResp.Status)
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "failed",
|
||||||
|
Message: &msg,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
msg := fmt.Sprintf("unknown order status: %s", statusResp.Status)
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "pending",
|
||||||
|
Message: &msg,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadCertificate downloads the PEM bundle for a DigiCert certificate.
|
||||||
|
func (c *Connector) downloadCertificate(ctx context.Context, certificateID int) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||||
|
downloadURL := fmt.Sprintf("%s/certificate/%d/download/format/pem_all", c.config.BaseURL, certificateID)
|
||||||
|
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||||
|
if reqErr != nil {
|
||||||
|
err = fmt.Errorf("failed to create download request: %w", reqErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
||||||
|
|
||||||
|
resp, doErr := c.httpClient.Do(req)
|
||||||
|
if doErr != nil {
|
||||||
|
err = fmt.Errorf("DigiCert download request failed: %w", doErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
err = fmt.Errorf("DigiCert download returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
if readErr != nil {
|
||||||
|
err = fmt.Errorf("failed to read download response: %w", readErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the PEM bundle: first cert is the leaf, rest are intermediates
|
||||||
|
certPEM, chainPEM, serial, notBefore, notAfter, err = parsePEMBundle(string(body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePEMBundle splits a PEM bundle into leaf cert and chain, extracting metadata.
|
||||||
|
func parsePEMBundle(bundle string) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
||||||
|
var certs []string
|
||||||
|
remaining := bundle
|
||||||
|
|
||||||
|
for {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest := pem.Decode([]byte(remaining))
|
||||||
|
if block == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if block.Type == "CERTIFICATE" {
|
||||||
|
certs = append(certs, string(pem.EncodeToMemory(block)))
|
||||||
|
}
|
||||||
|
remaining = string(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(certs) == 0 {
|
||||||
|
err = fmt.Errorf("no certificates found in PEM bundle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM = certs[0]
|
||||||
|
if len(certs) > 1 {
|
||||||
|
chainPEM = strings.Join(certs[1:], "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse leaf cert for metadata
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil {
|
||||||
|
err = fmt.Errorf("failed to decode leaf certificate PEM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, parseErr := x509.ParseCertificate(block.Bytes)
|
||||||
|
if parseErr != nil {
|
||||||
|
err = fmt.Errorf("failed to parse leaf certificate: %w", parseErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serial = cert.SerialNumber.String()
|
||||||
|
notBefore = cert.NotBefore
|
||||||
|
notAfter = cert.NotAfter
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCRL is not supported because DigiCert manages CRL distribution.
|
||||||
|
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("DigiCert manages CRL distribution; use DigiCert's CRL endpoints")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignOCSPResponse is not supported because DigiCert manages OCSP.
|
||||||
|
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("DigiCert manages OCSP; use DigiCert's OCSP responder")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM is not directly supported. DigiCert intermediate certificates
|
||||||
|
// come with each certificate issuance as part of the PEM bundle.
|
||||||
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
return "", fmt.Errorf("DigiCert intermediate certificates are included with each issued certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRenewalInfo returns nil, nil as DigiCert does not support ACME Renewal Information (ARI).
|
||||||
|
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Connector implements the issuer.Connector interface.
|
||||||
|
var _ issuer.Connector = (*Connector)(nil)
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
package digicert_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDigiCertConnector(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_Success", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/user/me" {
|
||||||
|
if r.Header.Get("X-DC-DEVKEY") == "dc-test-api-key" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"id":12345,"first_name":"Test","last_name":"User"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"errors":[{"code":"invalid_api_key"}]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := digicert.Config{
|
||||||
|
APIKey: "dc-test-api-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
ProductType: "ssl_basic",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := digicert.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingAPIKey", func(t *testing.T) {
|
||||||
|
config := digicert.Config{
|
||||||
|
OrgID: "12345",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := digicert.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing api_key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "api_key is required") {
|
||||||
|
t.Errorf("Expected api_key required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingOrgID", func(t *testing.T) {
|
||||||
|
config := digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := digicert.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing org_id")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "org_id is required") {
|
||||||
|
t.Errorf("Expected org_id required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_InvalidKey", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/user/me" {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"errors":[{"code":"invalid_api_key"}]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := digicert.Config{
|
||||||
|
APIKey: "dc-bad-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := digicert.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for invalid API key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid") {
|
||||||
|
t.Logf("Got error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_ImmediateSuccess", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
testChainPEM, _ := generateTestCert(t)
|
||||||
|
pemBundle := testCertPEM + testChainPEM
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/order/certificate/ssl_basic"):
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Write([]byte(`{"id":99001,"status":"issued","certificate_id":88001}`))
|
||||||
|
case r.URL.Path == "/certificate/88001/download/format/pem_all":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(pemBundle))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
ProductType: "ssl_basic",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "app.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "app.example.com",
|
||||||
|
SANs: []string{"app.example.com"},
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.CertPEM == "" {
|
||||||
|
t.Error("CertPEM should not be empty for immediate issuance")
|
||||||
|
}
|
||||||
|
if result.Serial == "" {
|
||||||
|
t.Error("Serial should not be empty for immediate issuance")
|
||||||
|
}
|
||||||
|
if result.OrderID != "99001" {
|
||||||
|
t.Errorf("Expected OrderID '99001', got '%s'", result.OrderID)
|
||||||
|
}
|
||||||
|
t.Logf("DigiCert issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_Pending", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/order/certificate/ssl_ev_basic"):
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Write([]byte(`{"id":99002,"status":"pending"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
ProductType: "ssl_ev_basic",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "secure.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "secure.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.OrderID != "99002" {
|
||||||
|
t.Errorf("Expected OrderID '99002', got '%s'", result.OrderID)
|
||||||
|
}
|
||||||
|
if result.CertPEM != "" {
|
||||||
|
t.Error("CertPEM should be empty for pending order")
|
||||||
|
}
|
||||||
|
if result.Serial != "" {
|
||||||
|
t.Error("Serial should be empty for pending order")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"errors":[{"code":"invalid_csr","message":"CSR is malformed"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
ProductType: "ssl_basic",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "test.example.com",
|
||||||
|
CSRPEM: "invalid-csr",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for server error response")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
testChainPEM, _ := generateTestCert(t)
|
||||||
|
pemBundle := testCertPEM + testChainPEM
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/order/certificate/99001":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"id":99001,"status":"issued","certificate":{"id":88001,"common_name":"app.example.com"}}`))
|
||||||
|
case "/certificate/88001/download/format/pem_all":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(pemBundle))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
status, err := connector.GetOrderStatus(ctx, "99001")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Status != "completed" {
|
||||||
|
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||||
|
}
|
||||||
|
if status.CertPEM == nil || *status.CertPEM == "" {
|
||||||
|
t.Error("CertPEM should not be empty for issued order")
|
||||||
|
}
|
||||||
|
if status.Serial == nil || *status.Serial == "" {
|
||||||
|
t.Error("Serial should not be empty for issued order")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_Pending", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/order/certificate/99002" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"id":99002,"status":"pending","certificate":{"id":0}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
status, err := connector.GetOrderStatus(ctx, "99002")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Status != "pending" {
|
||||||
|
t.Errorf("Expected status 'pending', got '%s'", status.Status)
|
||||||
|
}
|
||||||
|
if status.CertPEM != nil {
|
||||||
|
t.Error("CertPEM should be nil for pending order")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_Rejected", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/order/certificate/99003" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"id":99003,"status":"rejected","certificate":{"id":0}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
status, err := connector.GetOrderStatus(ctx, "99003")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Status != "failed" {
|
||||||
|
t.Errorf("Expected status 'failed', got '%s'", status.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RenewCertificate_NewOrder", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/order/certificate/"):
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Write([]byte(`{"id":99010,"status":"pending"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
ProductType: "ssl_basic",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "renew.example.com")
|
||||||
|
renewReq := issuer.RenewalRequest{
|
||||||
|
CommonName: "renew.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.RenewCertificate(ctx, renewReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenewCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.OrderID == "" {
|
||||||
|
t.Error("OrderID should not be empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
|
||||||
|
if r.Header.Get("X-DC-DEVKEY") == "" {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
reason := "keyCompromise"
|
||||||
|
revokeReq := issuer.RevocationRequest{
|
||||||
|
Serial: "88001",
|
||||||
|
Reason: &reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevokeCertificate_Error", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"errors":[{"code":"certificate_not_found"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
revokeReq := issuer.RevocationRequest{
|
||||||
|
Serial: "00000",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for revocation of nonexistent cert")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_DownloadError", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/order/certificate/99004":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"id":99004,"status":"issued","certificate":{"id":88004}}`))
|
||||||
|
case "/certificate/88004/download/format/pem_all":
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`{"errors":["internal server error"]}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
_, err := connector.GetOrderStatus(ctx, "99004")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error when download fails")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "download") {
|
||||||
|
t.Logf("Got error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
BaseURL: "https://api.digicert.com",
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
t.Fatal("GetRenewalInfo should return nil for DigiCert")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DefaultProductType", func(t *testing.T) {
|
||||||
|
config := &digicert.Config{
|
||||||
|
APIKey: "dc-test-key",
|
||||||
|
OrgID: "12345",
|
||||||
|
// ProductType intentionally left empty
|
||||||
|
}
|
||||||
|
connector := digicert.New(config, logger)
|
||||||
|
|
||||||
|
// Verify the connector was created (the default is set in New())
|
||||||
|
if connector == nil {
|
||||||
|
t.Fatal("Connector should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify via a request that uses the product type
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify the path includes the default product type
|
||||||
|
if strings.Contains(r.URL.Path, "ssl_basic") {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Write([]byte(`{"id":99099,"status":"pending"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Errorf("Expected path to contain 'ssl_basic', got: %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Reconfigure with test server URL
|
||||||
|
config.BaseURL = srv.URL
|
||||||
|
connector = digicert.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "test.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate with default product type failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.OrderID == "" {
|
||||||
|
t.Error("OrderID should not be empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
|
||||||
|
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: fmt.Sprintf("Test Certificate %s", serial.String()[:8]),
|
||||||
|
},
|
||||||
|
DNSNames: []string{"test.example.com"},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
|
||||||
|
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||||
|
|
||||||
|
return certPEM, keyPEM
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCSR creates a test CSR for the given common name.
|
||||||
|
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrTemplate := x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: commonName,
|
||||||
|
},
|
||||||
|
DNSNames: []string{commonName},
|
||||||
|
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||||
|
}
|
||||||
|
|
||||||
|
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create CSR: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE REQUEST",
|
||||||
|
Bytes: csrBytes,
|
||||||
|
}))
|
||||||
|
|
||||||
|
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse CSR: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return csr, csrPEM
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,372 @@
|
|||||||
|
// Package vault implements the issuer.Connector interface for HashiCorp Vault PKI
|
||||||
|
// secrets engine.
|
||||||
|
//
|
||||||
|
// Vault PKI provides a full-featured private CA with certificate signing, revocation,
|
||||||
|
// CRL, and OCSP capabilities. This connector uses the Vault HTTP API to sign CSRs
|
||||||
|
// via the /v1/{mount}/sign/{role} endpoint, authenticated with a Vault token.
|
||||||
|
//
|
||||||
|
// Vault issues certificates synchronously (like step-ca), so GetOrderStatus always
|
||||||
|
// returns "completed". CRL and OCSP are delegated to Vault's own endpoints.
|
||||||
|
//
|
||||||
|
// Authentication: Vault token via X-Vault-Token header.
|
||||||
|
//
|
||||||
|
// Vault API used:
|
||||||
|
//
|
||||||
|
// GET /v1/sys/health - Health check
|
||||||
|
// POST /v1/{mount}/sign/{role} - Sign CSR
|
||||||
|
// POST /v1/{mount}/revoke - Revoke certificate
|
||||||
|
// GET /v1/{mount}/ca/pem - Get CA certificate
|
||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the Vault PKI issuer connector configuration.
|
||||||
|
type Config struct {
|
||||||
|
// Addr is the Vault server address (e.g., "https://vault.example.com:8200").
|
||||||
|
// Required. Set via CERTCTL_VAULT_ADDR environment variable.
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
|
||||||
|
// Token is the Vault token for authentication.
|
||||||
|
// Required. Set via CERTCTL_VAULT_TOKEN environment variable.
|
||||||
|
Token string `json:"token"`
|
||||||
|
|
||||||
|
// Mount is the PKI secrets engine mount path.
|
||||||
|
// Default: "pki". Set via CERTCTL_VAULT_MOUNT environment variable.
|
||||||
|
Mount string `json:"mount"`
|
||||||
|
|
||||||
|
// Role is the PKI role name used for signing certificates.
|
||||||
|
// Required. Set via CERTCTL_VAULT_ROLE environment variable.
|
||||||
|
Role string `json:"role"`
|
||||||
|
|
||||||
|
// TTL is the requested certificate TTL (e.g., "8760h" for 1 year).
|
||||||
|
// Default: "8760h". Set via CERTCTL_VAULT_TTL environment variable.
|
||||||
|
TTL string `json:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the issuer.Connector interface for Vault PKI.
|
||||||
|
type Connector struct {
|
||||||
|
config *Config
|
||||||
|
logger *slog.Logger
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Vault PKI connector with the given configuration and logger.
|
||||||
|
func New(config *Config, logger *slog.Logger) *Connector {
|
||||||
|
if config != nil {
|
||||||
|
if config.Mount == "" {
|
||||||
|
config.Mount = "pki"
|
||||||
|
}
|
||||||
|
if config.TTL == "" {
|
||||||
|
config.TTL = "8760h"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Connector{
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// vaultResponse is the standard Vault API response wrapper.
|
||||||
|
type vaultResponse struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// signData holds the data returned from the /sign endpoint.
|
||||||
|
type signData struct {
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
IssuingCA string `json:"issuing_ca"`
|
||||||
|
CAChain []string `json:"ca_chain"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
Expiration int64 `json:"expiration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig checks that the Vault configuration is valid and the server is reachable.
|
||||||
|
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||||
|
return fmt.Errorf("invalid Vault config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Addr == "" {
|
||||||
|
return fmt.Errorf("Vault addr is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Token == "" {
|
||||||
|
return fmt.Errorf("Vault token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Role == "" {
|
||||||
|
return fmt.Errorf("Vault role is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Mount == "" {
|
||||||
|
cfg.Mount = "pki"
|
||||||
|
}
|
||||||
|
if cfg.TTL == "" {
|
||||||
|
cfg.TTL = "8760h"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
healthURL := cfg.Addr + "/v1/sys/health"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create health check request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Vault not reachable at %s: %w", cfg.Addr, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Vault health returns 200 for initialized+unsealed, 429 for standby, 472 for DR secondary,
|
||||||
|
// 473 for perf standby, 501 for uninitialized, 503 for sealed
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusTooManyRequests {
|
||||||
|
return fmt.Errorf("Vault health check returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &cfg
|
||||||
|
c.logger.Info("Vault PKI configuration validated",
|
||||||
|
"addr", cfg.Addr,
|
||||||
|
"mount", cfg.Mount,
|
||||||
|
"role", cfg.Role)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueCertificate submits a CSR to Vault PKI for signing.
|
||||||
|
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||||
|
c.logger.Info("processing Vault PKI issuance request",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"san_count", len(request.SANs))
|
||||||
|
|
||||||
|
// Build the sign request body
|
||||||
|
signBody := map[string]interface{}{
|
||||||
|
"csr": request.CSRPEM,
|
||||||
|
"common_name": request.CommonName,
|
||||||
|
"ttl": c.config.TTL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(request.SANs) > 0 {
|
||||||
|
signBody["alt_names"] = strings.Join(request.SANs, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(signBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal sign request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /v1/{mount}/sign/{role}
|
||||||
|
signURL := fmt.Sprintf("%s/v1/%s/sign/%s", c.config.Addr, c.config.Mount, c.config.Role)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create sign request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Vault-Token", c.config.Token)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Vault sign request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read sign response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var vaultResp vaultResponse
|
||||||
|
if jsonErr := json.Unmarshal(respBody, &vaultResp); jsonErr == nil && len(vaultResp.Errors) > 0 {
|
||||||
|
return nil, fmt.Errorf("Vault sign returned status %d: %s", resp.StatusCode, strings.Join(vaultResp.Errors, "; "))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Vault sign returned status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the Vault response
|
||||||
|
var vaultResp vaultResponse
|
||||||
|
if err := json.Unmarshal(respBody, &vaultResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse Vault response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data signData
|
||||||
|
if err := json.Unmarshal(vaultResp.Data, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse Vault sign data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Certificate == "" {
|
||||||
|
return nil, fmt.Errorf("no certificate in Vault sign response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the leaf certificate to extract metadata
|
||||||
|
certPEM := data.Certificate
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode certificate PEM from Vault")
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build chain PEM from ca_chain or issuing_ca
|
||||||
|
var chainPEM string
|
||||||
|
if len(data.CAChain) > 0 {
|
||||||
|
chainPEM = strings.Join(data.CAChain, "\n")
|
||||||
|
} else if data.IssuingCA != "" {
|
||||||
|
chainPEM = data.IssuingCA
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize serial: Vault uses colon-separated hex (e.g., "aa:bb:cc"), convert to plain string
|
||||||
|
serial := normalizeSerial(data.SerialNumber)
|
||||||
|
|
||||||
|
orderID := fmt.Sprintf("vault-%s", serial)
|
||||||
|
|
||||||
|
c.logger.Info("Vault PKI certificate issued",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"serial", serial,
|
||||||
|
"not_after", cert.NotAfter)
|
||||||
|
|
||||||
|
return &issuer.IssuanceResult{
|
||||||
|
CertPEM: certPEM,
|
||||||
|
ChainPEM: chainPEM,
|
||||||
|
Serial: serial,
|
||||||
|
NotBefore: cert.NotBefore,
|
||||||
|
NotAfter: cert.NotAfter,
|
||||||
|
OrderID: orderID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewCertificate renews a certificate by creating a new signing request.
|
||||||
|
// For Vault PKI, renewal is functionally identical to issuance (new cert signed from CSR).
|
||||||
|
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||||
|
c.logger.Info("processing Vault PKI renewal request",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"san_count", len(request.SANs))
|
||||||
|
|
||||||
|
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||||
|
CommonName: request.CommonName,
|
||||||
|
SANs: request.SANs,
|
||||||
|
CSRPEM: request.CSRPEM,
|
||||||
|
EKUs: request.EKUs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeCertificate revokes a certificate at Vault PKI.
|
||||||
|
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||||
|
c.logger.Info("processing Vault PKI revocation request", "serial", request.Serial)
|
||||||
|
|
||||||
|
revokeBody := map[string]interface{}{
|
||||||
|
"serial_number": request.Serial,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(revokeBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal revoke request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeURL := fmt.Sprintf("%s/v1/%s/revoke", c.config.Addr, c.config.Mount)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create revoke request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Vault-Token", c.config.Token)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Vault revoke request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("Vault revoke returned status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("Vault PKI certificate revoked", "serial", request.Serial)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderStatus returns the status of a Vault PKI order.
|
||||||
|
// Vault signs synchronously, so orders are always "completed" immediately.
|
||||||
|
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||||
|
return &issuer.OrderStatus{
|
||||||
|
OrderID: orderID,
|
||||||
|
Status: "completed",
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCRL is not supported because Vault serves CRL directly at /v1/{mount}/crl.
|
||||||
|
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("Vault serves CRL directly at /v1/%s/crl; use Vault's endpoint", c.config.Mount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignOCSPResponse is not supported because Vault serves OCSP directly at /v1/{mount}/ocsp.
|
||||||
|
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("Vault serves OCSP directly at /v1/%s/ocsp; use Vault's endpoint", c.config.Mount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM retrieves the CA certificate from Vault PKI.
|
||||||
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
caURL := fmt.Sprintf("%s/v1/%s/ca/pem", c.config.Addr, c.config.Mount)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, caURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create CA cert request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Vault-Token", c.config.Token)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Vault CA cert request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("Vault CA cert returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read CA cert response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRenewalInfo returns nil, nil as Vault does not support ACME Renewal Information (ARI).
|
||||||
|
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeSerial converts Vault's colon-separated hex serial (e.g., "aa:bb:cc:dd")
|
||||||
|
// to a plain string representation suitable for storage.
|
||||||
|
func normalizeSerial(serial string) string {
|
||||||
|
return strings.ReplaceAll(serial, ":", "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Connector implements the issuer.Connector interface.
|
||||||
|
var _ issuer.Connector = (*Connector)(nil)
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
package vault_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVaultConnector(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_Success", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/sys/health" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"initialized":true,"sealed":false,"standby":false}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := vault.Config{
|
||||||
|
Addr: srv.URL,
|
||||||
|
Token: "s.test-token-12345",
|
||||||
|
Mount: "pki",
|
||||||
|
Role: "web-certs",
|
||||||
|
TTL: "8760h",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := vault.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingAddr", func(t *testing.T) {
|
||||||
|
config := vault.Config{
|
||||||
|
Token: "s.test-token",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := vault.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing addr")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "addr is required") {
|
||||||
|
t.Errorf("Expected addr required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingToken", func(t *testing.T) {
|
||||||
|
config := vault.Config{
|
||||||
|
Addr: "https://vault.example.com:8200",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := vault.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing token")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "token is required") {
|
||||||
|
t.Errorf("Expected token required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingRole", func(t *testing.T) {
|
||||||
|
config := vault.Config{
|
||||||
|
Addr: "https://vault.example.com:8200",
|
||||||
|
Token: "s.test-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := vault.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing role")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "role is required") {
|
||||||
|
t.Errorf("Expected role required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_UnreachableVault", func(t *testing.T) {
|
||||||
|
config := vault.Config{
|
||||||
|
Addr: "http://localhost:19999",
|
||||||
|
Token: "s.test-token",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := vault.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for unreachable Vault")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_Success", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/v1/sys/health":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"initialized":true,"sealed":false}`))
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||||
|
// Verify auth header
|
||||||
|
if r.Header.Get("X-Vault-Token") != "s.test-token" {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"errors":["permission denied"]}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := fmt.Sprintf(`{
|
||||||
|
"data": {
|
||||||
|
"certificate": %q,
|
||||||
|
"issuing_ca": %q,
|
||||||
|
"ca_chain": [%q],
|
||||||
|
"serial_number": "aa:bb:cc:dd:ee:ff",
|
||||||
|
"expiration": 1893456000
|
||||||
|
}
|
||||||
|
}`, testCertPEM, testCertPEM, testCertPEM)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &vault.Config{
|
||||||
|
Addr: srv.URL,
|
||||||
|
Token: "s.test-token",
|
||||||
|
Mount: "pki",
|
||||||
|
Role: "web-certs",
|
||||||
|
TTL: "8760h",
|
||||||
|
}
|
||||||
|
connector := vault.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "app.example.com")
|
||||||
|
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "app.example.com",
|
||||||
|
SANs: []string{"app.example.com", "www.example.com"},
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.CertPEM == "" {
|
||||||
|
t.Error("CertPEM is empty")
|
||||||
|
}
|
||||||
|
if result.Serial == "" {
|
||||||
|
t.Error("Serial is empty")
|
||||||
|
}
|
||||||
|
if result.OrderID == "" {
|
||||||
|
t.Error("OrderID is empty")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(result.OrderID, "vault-") {
|
||||||
|
t.Errorf("Expected OrderID to start with 'vault-', got '%s'", result.OrderID)
|
||||||
|
}
|
||||||
|
// Verify serial normalization (colons replaced with dashes)
|
||||||
|
if strings.Contains(result.Serial, ":") {
|
||||||
|
t.Errorf("Serial should not contain colons, got '%s'", result.Serial)
|
||||||
|
}
|
||||||
|
t.Logf("Vault issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/v1/sys/health":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"errors":["invalid CSR"]}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &vault.Config{
|
||||||
|
Addr: srv.URL,
|
||||||
|
Token: "s.test-token",
|
||||||
|
Mount: "pki",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
connector := vault.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "test.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for server error response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid CSR") {
|
||||||
|
t.Logf("Got error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_Forbidden", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/v1/sys/health":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"errors":["permission denied"]}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &vault.Config{
|
||||||
|
Addr: srv.URL,
|
||||||
|
Token: "s.bad-token",
|
||||||
|
Mount: "pki",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
connector := vault.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "test.example.com")
|
||||||
|
req := issuer.IssuanceRequest{
|
||||||
|
CommonName: "test.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := connector.IssueCertificate(ctx, req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for forbidden response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "permission denied") {
|
||||||
|
t.Logf("Got error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RenewCertificate_Success", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/v1/sys/health":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := fmt.Sprintf(`{
|
||||||
|
"data": {
|
||||||
|
"certificate": %q,
|
||||||
|
"issuing_ca": %q,
|
||||||
|
"serial_number": "11:22:33:44:55:66",
|
||||||
|
"expiration": 1893456000
|
||||||
|
}
|
||||||
|
}`, testCertPEM, testCertPEM)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &vault.Config{
|
||||||
|
Addr: srv.URL,
|
||||||
|
Token: "s.test-token",
|
||||||
|
Mount: "pki",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
connector := vault.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "renew.example.com")
|
||||||
|
renewReq := issuer.RenewalRequest{
|
||||||
|
CommonName: "renew.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := connector.RenewCertificate(ctx, renewReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenewCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Serial == "" {
|
||||||
|
t.Error("Serial is empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/sys/health":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case "/v1/pki/revoke":
|
||||||
|
// Verify token
|
||||||
|
if r.Header.Get("X-Vault-Token") == "" {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"data":{"revocation_time":1234567890}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &vault.Config{
|
||||||
|
Addr: srv.URL,
|
||||||
|
Token: "s.test-token",
|
||||||
|
Mount: "pki",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
connector := vault.New(config, logger)
|
||||||
|
|
||||||
|
reason := "keyCompromise"
|
||||||
|
revokeReq := issuer.RevocationRequest{
|
||||||
|
Serial: "aa-bb-cc-dd-ee-ff",
|
||||||
|
Reason: &reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevokeCertificate_ServerError", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/sys/health":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case "/v1/pki/revoke":
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"errors":["serial not found"]}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &vault.Config{
|
||||||
|
Addr: srv.URL,
|
||||||
|
Token: "s.test-token",
|
||||||
|
Mount: "pki",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
connector := vault.New(config, logger)
|
||||||
|
|
||||||
|
revokeReq := issuer.RevocationRequest{
|
||||||
|
Serial: "00-00-00-00",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for server error response")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetCACertPEM_Success", func(t *testing.T) {
|
||||||
|
expectedPEM := "-----BEGIN CERTIFICATE-----\nTESTCA\n-----END CERTIFICATE-----\n"
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/pki/ca/pem":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(expectedPEM))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &vault.Config{
|
||||||
|
Addr: srv.URL,
|
||||||
|
Token: "s.test-token",
|
||||||
|
Mount: "pki",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
connector := vault.New(config, logger)
|
||||||
|
|
||||||
|
caPEM, err := connector.GetCACertPEM(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCACertPEM failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if caPEM != expectedPEM {
|
||||||
|
t.Errorf("Expected CA PEM %q, got %q", expectedPEM, caPEM)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_Synchronous", func(t *testing.T) {
|
||||||
|
config := &vault.Config{
|
||||||
|
Addr: "https://vault.example.com:8200",
|
||||||
|
Token: "s.test-token",
|
||||||
|
Mount: "pki",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
connector := vault.New(config, logger)
|
||||||
|
|
||||||
|
status, err := connector.GetOrderStatus(ctx, "vault-aa-bb-cc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrderStatus failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Status != "completed" {
|
||||||
|
t.Errorf("Expected status 'completed', got '%s'", status.Status)
|
||||||
|
}
|
||||||
|
if status.OrderID != "vault-aa-bb-cc" {
|
||||||
|
t.Errorf("Expected OrderID 'vault-aa-bb-cc', got '%s'", status.OrderID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||||
|
config := &vault.Config{
|
||||||
|
Addr: "https://vault.example.com:8200",
|
||||||
|
Token: "s.test-token",
|
||||||
|
Mount: "pki",
|
||||||
|
Role: "web-certs",
|
||||||
|
}
|
||||||
|
connector := vault.New(config, logger)
|
||||||
|
|
||||||
|
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
t.Fatal("GetRenewalInfo should return nil for Vault")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
|
||||||
|
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: "Test Certificate",
|
||||||
|
},
|
||||||
|
DNSNames: []string{"test.example.com"},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
|
||||||
|
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||||
|
|
||||||
|
return certPEM, keyPEM
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestCSR creates a test CSR for the given common name.
|
||||||
|
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrTemplate := x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: commonName,
|
||||||
|
},
|
||||||
|
DNSNames: []string{commonName},
|
||||||
|
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||||
|
}
|
||||||
|
|
||||||
|
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create CSR: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE REQUEST",
|
||||||
|
Bytes: csrBytes,
|
||||||
|
}))
|
||||||
|
|
||||||
|
csr, err := x509.ParseCertificateRequest(csrBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse CSR: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return csr, csrPEM
|
||||||
|
}
|
||||||
@@ -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(`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ const (
|
|||||||
IssuerTypeGenericCA IssuerType = "GenericCA"
|
IssuerTypeGenericCA IssuerType = "GenericCA"
|
||||||
IssuerTypeStepCA IssuerType = "StepCA"
|
IssuerTypeStepCA IssuerType = "StepCA"
|
||||||
IssuerTypeOpenSSL IssuerType = "OpenSSL"
|
IssuerTypeOpenSSL IssuerType = "OpenSSL"
|
||||||
|
IssuerTypeVault IssuerType = "VaultPKI"
|
||||||
|
IssuerTypeDigiCert IssuerType = "DigiCert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TargetType represents the type of deployment target.
|
// TargetType represents the type of deployment target.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Job struct {
|
|||||||
Type JobType `json:"type"`
|
Type JobType `json:"type"`
|
||||||
CertificateID string `json:"certificate_id"`
|
CertificateID string `json:"certificate_id"`
|
||||||
TargetID *string `json:"target_id,omitempty"`
|
TargetID *string `json:"target_id,omitempty"`
|
||||||
|
AgentID *string `json:"agent_id,omitempty"`
|
||||||
Status JobStatus `json:"status"`
|
Status JobStatus `json:"status"`
|
||||||
Attempts int `json:"attempts"`
|
Attempts int `json:"attempts"`
|
||||||
MaxAttempts int `json:"max_attempts"`
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
|||||||
@@ -662,6 +662,20 @@ func (m *mockJobRepository) GetPendingJobs(ctx context.Context, jobType domain.J
|
|||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockJobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||||
|
var result []*domain.Job
|
||||||
|
for _, j := range m.jobs {
|
||||||
|
if j.AgentID != nil && *j.AgentID == agentID {
|
||||||
|
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
|
||||||
|
result = append(result, j)
|
||||||
|
} else if j.Status == domain.JobStatusAwaitingCSR {
|
||||||
|
result = append(result, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockAuditRepository struct {
|
type mockAuditRepository struct {
|
||||||
events []*domain.AuditEvent
|
events []*domain.AuditEvent
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-4
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
|
|||||||
|
|
||||||
gomcp.AddTool(s, &gomcp.Tool{
|
gomcp.AddTool(s, &gomcp.Tool{
|
||||||
Name: "certctl_create_certificate",
|
Name: "certctl_create_certificate",
|
||||||
Description: "Create a new managed certificate. Requires common_name and issuer_id at minimum.",
|
Description: "Create a new managed certificate. Requires name, common_name, renewal_policy_id, issuer_id, owner_id, and team_id.",
|
||||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateCertificateInput) (*gomcp.CallToolResult, any, error) {
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateCertificateInput) (*gomcp.CallToolResult, any, error) {
|
||||||
data, err := c.Post("/api/v1/certificates", input)
|
data, err := c.Post("/api/v1/certificates", input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,7 +144,7 @@ func registerCertificateTools(s *gomcp.Server, c *Client) {
|
|||||||
|
|
||||||
gomcp.AddTool(s, &gomcp.Tool{
|
gomcp.AddTool(s, &gomcp.Tool{
|
||||||
Name: "certctl_trigger_renewal",
|
Name: "certctl_trigger_renewal",
|
||||||
Description: "Trigger immediate renewal of a certificate. Creates a renewal job (async, returns 202).",
|
Description: "Trigger immediate renewal of a certificate. Creates a renewal job (async, returns 202). Returns 404 if certificate not found, 400 if certificate is archived/expired, 409 if renewal already in progress.",
|
||||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
||||||
data, err := c.Post("/api/v1/certificates/"+input.ID+"/renew", nil)
|
data, err := c.Post("/api/v1/certificates/"+input.ID+"/renew", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -384,7 +385,7 @@ func registerAgentTools(s *gomcp.Server, c *Client) {
|
|||||||
|
|
||||||
gomcp.AddTool(s, &gomcp.Tool{
|
gomcp.AddTool(s, &gomcp.Tool{
|
||||||
Name: "certctl_register_agent",
|
Name: "certctl_register_agent",
|
||||||
Description: "Register a new agent. Requires name and hostname.",
|
Description: "Register a new agent. Requires name and hostname. Returns 409 if an agent with the same name already exists.",
|
||||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input RegisterAgentInput) (*gomcp.CallToolResult, any, error) {
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input RegisterAgentInput) (*gomcp.CallToolResult, any, error) {
|
||||||
data, err := c.Post("/api/v1/agents", input)
|
data, err := c.Post("/api/v1/agents", input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -395,7 +396,7 @@ func registerAgentTools(s *gomcp.Server, c *Client) {
|
|||||||
|
|
||||||
gomcp.AddTool(s, &gomcp.Tool{
|
gomcp.AddTool(s, &gomcp.Tool{
|
||||||
Name: "certctl_agent_heartbeat",
|
Name: "certctl_agent_heartbeat",
|
||||||
Description: "Send agent heartbeat with optional metadata (OS, architecture, IP, version).",
|
Description: "Send agent heartbeat with optional metadata (OS, architecture, IP, version). Returns 404 if agent not found.",
|
||||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input struct {
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input struct {
|
||||||
ID string `json:"id" jsonschema:"Agent ID"`
|
ID string `json:"id" jsonschema:"Agent ID"`
|
||||||
Version string `json:"version,omitempty" jsonschema:"Agent version"`
|
Version string `json:"version,omitempty" jsonschema:"Agent version"`
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ type JobRepository interface {
|
|||||||
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
|
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
|
||||||
// GetPendingJobs returns jobs not yet processed of a specific type.
|
// GetPendingJobs returns jobs not yet processed of a specific type.
|
||||||
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
|
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
|
||||||
|
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
|
||||||
|
ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenewalPolicyRepository defines operations for managing renewal policies.
|
// RenewalPolicyRepository defines operations for managing renewal policies.
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ func (r *CertificateRepository) Archive(ctx context.Context, id string) error {
|
|||||||
func (r *CertificateRepository) ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
|
func (r *CertificateRepository) ListVersions(ctx context.Context, certID string) ([]*domain.CertificateVersion, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, certificate_id, serial_number, not_before, not_after,
|
SELECT id, certificate_id, serial_number, not_before, not_after,
|
||||||
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
|
fingerprint_sha256, pem_chain, csr_pem, created_at
|
||||||
FROM certificate_versions
|
FROM certificate_versions
|
||||||
WHERE certificate_id = $1
|
WHERE certificate_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -363,10 +363,12 @@ func (r *CertificateRepository) ListVersions(ctx context.Context, certID string)
|
|||||||
var versions []*domain.CertificateVersion
|
var versions []*domain.CertificateVersion
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var v domain.CertificateVersion
|
var v domain.CertificateVersion
|
||||||
|
var csrPEM sql.NullString
|
||||||
if err := rows.Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
|
if err := rows.Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
|
||||||
&v.FingerprintSHA256, &v.PEMChain, &v.CSRPEM, &v.KeyAlgorithm, &v.KeySize, &v.CreatedAt); err != nil {
|
&v.FingerprintSHA256, &v.PEMChain, &csrPEM, &v.CreatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan certificate version: %w", err)
|
return nil, fmt.Errorf("failed to scan certificate version: %w", err)
|
||||||
}
|
}
|
||||||
|
v.CSRPEM = csrPEM.String
|
||||||
versions = append(versions, &v)
|
versions = append(versions, &v)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,11 +388,11 @@ func (r *CertificateRepository) CreateVersion(ctx context.Context, version *doma
|
|||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO certificate_versions (
|
INSERT INTO certificate_versions (
|
||||||
id, certificate_id, serial_number, not_before, not_after,
|
id, certificate_id, serial_number, not_before, not_after,
|
||||||
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
|
fingerprint_sha256, pem_chain, csr_pem, created_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, version.ID, version.CertificateID, version.SerialNumber, version.NotBefore, version.NotAfter,
|
`, version.ID, version.CertificateID, version.SerialNumber, version.NotBefore, version.NotAfter,
|
||||||
version.FingerprintSHA256, version.PEMChain, version.CSRPEM, version.KeyAlgorithm, version.KeySize, version.CreatedAt).Scan(&version.ID)
|
version.FingerprintSHA256, version.PEMChain, version.CSRPEM, version.CreatedAt).Scan(&version.ID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create certificate version: %w", err)
|
return fmt.Errorf("failed to create certificate version: %w", err)
|
||||||
@@ -433,15 +435,17 @@ func (r *CertificateRepository) GetExpiringCertificates(ctx context.Context, bef
|
|||||||
// GetLatestVersion returns the most recent certificate version for a certificate.
|
// GetLatestVersion returns the most recent certificate version for a certificate.
|
||||||
func (r *CertificateRepository) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
|
func (r *CertificateRepository) GetLatestVersion(ctx context.Context, certID string) (*domain.CertificateVersion, error) {
|
||||||
var v domain.CertificateVersion
|
var v domain.CertificateVersion
|
||||||
|
var csrPEM sql.NullString
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, certificate_id, serial_number, not_before, not_after,
|
SELECT id, certificate_id, serial_number, not_before, not_after,
|
||||||
fingerprint_sha256, pem_chain, csr_pem, key_algorithm, key_size, created_at
|
fingerprint_sha256, pem_chain, csr_pem, created_at
|
||||||
FROM certificate_versions
|
FROM certificate_versions
|
||||||
WHERE certificate_id = $1
|
WHERE certificate_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, certID).Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
|
`, certID).Scan(&v.ID, &v.CertificateID, &v.SerialNumber, &v.NotBefore, &v.NotAfter,
|
||||||
&v.FingerprintSHA256, &v.PEMChain, &v.CSRPEM, &v.KeyAlgorithm, &v.KeySize, &v.CreatedAt)
|
&v.FingerprintSHA256, &v.PEMChain, &csrPEM, &v.CreatedAt)
|
||||||
|
v.CSRPEM = csrPEM.String
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get latest certificate version: %w", err)
|
return nil, fmt.Errorf("failed to get latest certificate version: %w", err)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func NewJobRepository(db *sql.DB) *JobRepository {
|
|||||||
// List returns all jobs
|
// List returns all jobs
|
||||||
func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -52,7 +52,7 @@ func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
|
|||||||
// Get retrieves a job by ID
|
// Get retrieves a job by ID
|
||||||
func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
|
||||||
row := r.db.QueryRowContext(ctx, `
|
row := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@@ -77,11 +77,11 @@ func (r *JobRepository) Create(ctx context.Context, job *domain.Job) error {
|
|||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
INSERT INTO jobs (
|
INSERT INTO jobs (
|
||||||
id, type, certificate_id, target_id, status, attempts, max_attempts,
|
id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
|
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
|
||||||
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.CompletedAt,
|
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.CompletedAt,
|
||||||
job.CreatedAt).Scan(&job.ID)
|
job.CreatedAt).Scan(&job.ID)
|
||||||
|
|
||||||
@@ -99,15 +99,16 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
|
|||||||
type = $1,
|
type = $1,
|
||||||
certificate_id = $2,
|
certificate_id = $2,
|
||||||
target_id = $3,
|
target_id = $3,
|
||||||
status = $4,
|
agent_id = $4,
|
||||||
attempts = $5,
|
status = $5,
|
||||||
max_attempts = $6,
|
attempts = $6,
|
||||||
last_error = $7,
|
max_attempts = $7,
|
||||||
scheduled_at = $8,
|
last_error = $8,
|
||||||
started_at = $9,
|
scheduled_at = $9,
|
||||||
completed_at = $10
|
started_at = $10,
|
||||||
WHERE id = $11
|
completed_at = $11
|
||||||
`, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts,
|
WHERE id = $12
|
||||||
|
`, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
|
||||||
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt,
|
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt,
|
||||||
job.CompletedAt, job.ID)
|
job.CompletedAt, job.ID)
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
|
|||||||
// ListByStatus returns jobs with a specific status
|
// ListByStatus returns jobs with a specific status
|
||||||
func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
|
func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE status = $1
|
WHERE status = $1
|
||||||
@@ -181,7 +182,7 @@ func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatu
|
|||||||
// ListByCertificate returns all jobs for a certificate
|
// ListByCertificate returns all jobs for a certificate
|
||||||
func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
|
func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE certificate_id = $1
|
WHERE certificate_id = $1
|
||||||
@@ -239,7 +240,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
|
|||||||
// GetPendingJobs returns jobs not yet processed of a specific type
|
// GetPendingJobs returns jobs not yet processed of a specific type
|
||||||
func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
|
func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts,
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
last_error, scheduled_at, started_at, completed_at, created_at
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE type = $1 AND status = $2
|
WHERE type = $1 AND status = $2
|
||||||
@@ -267,13 +268,71 @@ func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobTy
|
|||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
|
||||||
|
// Deployment jobs are matched by agent_id directly (set at creation time), with a fallback
|
||||||
|
// for legacy jobs where agent_id is NULL but target_id resolves to the agent via deployment_targets.
|
||||||
|
// AwaitingCSR jobs are matched through certificate → target mappings → agent ownership.
|
||||||
|
func (r *JobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
|
||||||
|
last_error, scheduled_at, started_at, completed_at, created_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE agent_id = $1 AND status = 'Pending' AND type = 'Deployment'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
|
||||||
|
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
|
||||||
|
FROM jobs j
|
||||||
|
INNER JOIN deployment_targets dt ON j.target_id = dt.id
|
||||||
|
WHERE j.agent_id IS NULL AND j.status = 'Pending' AND j.type = 'Deployment'
|
||||||
|
AND dt.agent_id = $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
|
||||||
|
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
|
||||||
|
FROM jobs j
|
||||||
|
WHERE j.status = 'AwaitingCSR'
|
||||||
|
AND j.type IN ('Renewal', 'Issuance')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM certificate_target_mappings ctm
|
||||||
|
INNER JOIN deployment_targets dt ON ctm.target_id = dt.id
|
||||||
|
WHERE ctm.certificate_id = j.certificate_id
|
||||||
|
AND dt.agent_id = $1
|
||||||
|
)
|
||||||
|
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`, agentID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query pending jobs for agent: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var jobs []*domain.Job
|
||||||
|
for rows.Next() {
|
||||||
|
job, err := scanJob(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jobs = append(jobs, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error iterating pending agent job rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// scanJob scans a job from a row or rows
|
// scanJob scans a job from a row or rows
|
||||||
func scanJob(scanner interface {
|
func scanJob(scanner interface {
|
||||||
Scan(...interface{}) error
|
Scan(...interface{}) error
|
||||||
}) (*domain.Job, error) {
|
}) (*domain.Job, error) {
|
||||||
var job domain.Job
|
var job domain.Job
|
||||||
err := scanner.Scan(&job.ID, &job.Type, &job.CertificateID, &job.TargetID,
|
err := scanner.Scan(&job.ID, &job.Type, &job.CertificateID, &job.TargetID,
|
||||||
&job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
|
&job.AgentID, &job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
|
||||||
&job.ScheduledAt, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
|
&job.ScheduledAt, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -251,38 +251,17 @@ func (s *AgentService) GetCertificateForAgent(ctx context.Context, agentID strin
|
|||||||
|
|
||||||
// GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and
|
// GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and
|
||||||
// renewal/issuance jobs awaiting CSR submission (AwaitingCSR).
|
// renewal/issuance jobs awaiting CSR submission (AwaitingCSR).
|
||||||
|
// Jobs are scoped to the requesting agent via agent_id (set at job creation) or
|
||||||
|
// through target→agent relationships for legacy jobs and AwaitingCSR routing.
|
||||||
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
|
||||||
// Fetch agent to verify it exists
|
// Verify agent exists
|
||||||
_, err := s.agentRepo.Get(ctx, agentID)
|
_, err := s.agentRepo.Get(ctx, agentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
return nil, fmt.Errorf("failed to fetch agent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var workForAgent []*domain.Job
|
// Return only jobs assigned to this agent (via agent_id or target→agent relationship)
|
||||||
|
return s.jobRepo.ListPendingByAgentID(ctx, agentID)
|
||||||
// Get pending deployment jobs
|
|
||||||
pendingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list pending jobs: %w", err)
|
|
||||||
}
|
|
||||||
for _, job := range pendingJobs {
|
|
||||||
if job.Type == domain.JobTypeDeployment {
|
|
||||||
workForAgent = append(workForAgent, job)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get AwaitingCSR jobs (agent keygen mode — agent needs to generate key + submit CSR)
|
|
||||||
awaitingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusAwaitingCSR)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list awaiting CSR jobs: %w", err)
|
|
||||||
}
|
|
||||||
for _, job := range awaitingJobs {
|
|
||||||
if job.Type == domain.JobTypeRenewal || job.Type == domain.JobTypeIssuance {
|
|
||||||
workForAgent = append(workForAgent, job)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return workForAgent, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportJobStatus updates a job's status based on agent feedback.
|
// ReportJobStatus updates a job's status based on agent feedback.
|
||||||
|
|||||||
@@ -131,8 +131,9 @@ func TestHeartbeat_NotFound(t *testing.T) {
|
|||||||
func TestGetPendingWork(t *testing.T) {
|
func TestGetPendingWork(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
agentID := "agent-001"
|
||||||
agent := &domain.Agent{
|
agent := &domain.Agent{
|
||||||
ID: "agent-001",
|
ID: agentID,
|
||||||
Name: "prod-agent",
|
Name: "prod-agent",
|
||||||
Hostname: "server-01",
|
Hostname: "server-01",
|
||||||
Status: domain.AgentStatusOnline,
|
Status: domain.AgentStatusOnline,
|
||||||
@@ -146,6 +147,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
Type: domain.JobTypeDeployment,
|
Type: domain.JobTypeDeployment,
|
||||||
CertificateID: "cert-001",
|
CertificateID: "cert-001",
|
||||||
Status: domain.JobStatusPending,
|
Status: domain.JobStatusPending,
|
||||||
|
AgentID: &agentID,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
}
|
}
|
||||||
job2 := &domain.Job{
|
job2 := &domain.Job{
|
||||||
@@ -157,7 +159,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agentRepo := &mockAgentRepo{
|
agentRepo := &mockAgentRepo{
|
||||||
Agents: map[string]*domain.Agent{"agent-001": agent},
|
Agents: map[string]*domain.Agent{agentID: agent},
|
||||||
HeartbeatUpdates: make(map[string]time.Time),
|
HeartbeatUpdates: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
certRepo := &mockCertRepo{
|
certRepo := &mockCertRepo{
|
||||||
@@ -177,7 +179,7 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
|
|
||||||
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
|
||||||
|
|
||||||
jobs, err := agentService.GetPendingWork(ctx, "agent-001")
|
jobs, err := agentService.GetPendingWork(ctx, agentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetPendingWork failed: %v", err)
|
t.Fatalf("GetPendingWork failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -185,11 +187,132 @@ func TestGetPendingWork(t *testing.T) {
|
|||||||
if len(jobs) != 1 {
|
if len(jobs) != 1 {
|
||||||
t.Errorf("expected 1 deployment job, got %d", len(jobs))
|
t.Errorf("expected 1 deployment job, got %d", len(jobs))
|
||||||
}
|
}
|
||||||
if jobs[0].Type != domain.JobTypeDeployment {
|
if len(jobs) > 0 && jobs[0].Type != domain.JobTypeDeployment {
|
||||||
t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type)
|
t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetPendingWork_OnlyReturnsAgentJobs(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
agentA := "agent-A"
|
||||||
|
agentB := "agent-B"
|
||||||
|
|
||||||
|
agentRepo := &mockAgentRepo{
|
||||||
|
Agents: map[string]*domain.Agent{
|
||||||
|
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||||
|
agentB: {ID: agentB, Name: "agent-B", Hostname: "host-b", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashB"},
|
||||||
|
},
|
||||||
|
HeartbeatUpdates: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
jobA := &domain.Job{ID: "job-A", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
|
||||||
|
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-002", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
|
||||||
|
|
||||||
|
jobRepo := &mockJobRepo{
|
||||||
|
Jobs: map[string]*domain.Job{"job-A": jobA, "job-B": jobB},
|
||||||
|
StatusUpdates: make(map[string]domain.JobStatus),
|
||||||
|
}
|
||||||
|
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||||
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||||
|
|
||||||
|
// Agent A should only see its job
|
||||||
|
jobsA, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPendingWork for agent-A failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(jobsA) != 1 {
|
||||||
|
t.Fatalf("expected 1 job for agent-A, got %d", len(jobsA))
|
||||||
|
}
|
||||||
|
if jobsA[0].ID != "job-A" {
|
||||||
|
t.Errorf("expected job-A, got %s", jobsA[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent B should only see its job
|
||||||
|
jobsB, err := agentService.GetPendingWork(ctx, agentB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPendingWork for agent-B failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(jobsB) != 1 {
|
||||||
|
t.Fatalf("expected 1 job for agent-B, got %d", len(jobsB))
|
||||||
|
}
|
||||||
|
if jobsB[0].ID != "job-B" {
|
||||||
|
t.Errorf("expected job-B, got %s", jobsB[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
agentA := "agent-A"
|
||||||
|
agentB := "agent-B"
|
||||||
|
|
||||||
|
agentRepo := &mockAgentRepo{
|
||||||
|
Agents: map[string]*domain.Agent{
|
||||||
|
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||||
|
},
|
||||||
|
HeartbeatUpdates: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
// All jobs belong to agent-B
|
||||||
|
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
|
||||||
|
|
||||||
|
jobRepo := &mockJobRepo{
|
||||||
|
Jobs: map[string]*domain.Job{"job-B": jobB},
|
||||||
|
StatusUpdates: make(map[string]domain.JobStatus),
|
||||||
|
}
|
||||||
|
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||||
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||||
|
|
||||||
|
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPendingWork failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(jobs) != 0 {
|
||||||
|
t.Errorf("expected 0 jobs for agent-A (all jobs are for agent-B), got %d", len(jobs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
agentA := "agent-A"
|
||||||
|
|
||||||
|
agentRepo := &mockAgentRepo{
|
||||||
|
Agents: map[string]*domain.Agent{
|
||||||
|
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
|
||||||
|
},
|
||||||
|
HeartbeatUpdates: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
deployJob := &domain.Job{ID: "job-deploy", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
|
||||||
|
csrJob := &domain.Job{ID: "job-csr", Type: domain.JobTypeRenewal, CertificateID: "cert-002", Status: domain.JobStatusAwaitingCSR, AgentID: &agentA, CreatedAt: now}
|
||||||
|
|
||||||
|
jobRepo := &mockJobRepo{
|
||||||
|
Jobs: map[string]*domain.Job{"job-deploy": deployJob, "job-csr": csrJob},
|
||||||
|
StatusUpdates: make(map[string]domain.JobStatus),
|
||||||
|
}
|
||||||
|
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
|
||||||
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||||
|
auditService := NewAuditService(&mockAuditRepo{})
|
||||||
|
|
||||||
|
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
|
||||||
|
|
||||||
|
jobs, err := agentService.GetPendingWork(ctx, agentA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPendingWork failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(jobs) != 2 {
|
||||||
|
t.Fatalf("expected 2 jobs (deployment + AwaitingCSR), got %d", len(jobs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReportJobStatus(t *testing.T) {
|
func TestReportJobStatus(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -304,6 +304,14 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
|
|||||||
if cert.UpdatedAt.IsZero() {
|
if cert.UpdatedAt.IsZero() {
|
||||||
cert.UpdatedAt = now
|
cert.UpdatedAt = now
|
||||||
}
|
}
|
||||||
|
// Default status to Pending if not set (DB column DEFAULT only applies when column is omitted from INSERT)
|
||||||
|
if cert.Status == "" {
|
||||||
|
cert.Status = domain.CertificateStatusPending
|
||||||
|
}
|
||||||
|
// Default tags to empty map if nil (avoids JSON null in JSONB column)
|
||||||
|
if cert.Tags == nil {
|
||||||
|
cert.Tags = make(map[string]string)
|
||||||
|
}
|
||||||
if err := s.certRepo.Create(context.Background(), &cert); err != nil {
|
if err := s.certRepo.Create(context.Background(), &cert); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create certificate: %w", err)
|
return nil, fmt.Errorf("failed to create certificate: %w", err)
|
||||||
}
|
}
|
||||||
@@ -311,12 +319,56 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCertificate modifies a certificate (handler interface method).
|
// UpdateCertificate modifies a certificate (handler interface method).
|
||||||
func (s *CertificateService) UpdateCertificate(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
func (s *CertificateService) UpdateCertificate(id string, patch domain.ManagedCertificate) (*domain.ManagedCertificate, error) {
|
||||||
cert.ID = id
|
ctx := context.Background()
|
||||||
if err := s.certRepo.Update(context.Background(), &cert); err != nil {
|
|
||||||
|
// Fetch existing certificate so partial updates don't zero out fields
|
||||||
|
existing, err := s.certRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("certificate not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge non-zero fields from patch into existing
|
||||||
|
if patch.Name != "" {
|
||||||
|
existing.Name = patch.Name
|
||||||
|
}
|
||||||
|
if patch.CommonName != "" {
|
||||||
|
existing.CommonName = patch.CommonName
|
||||||
|
}
|
||||||
|
if len(patch.SANs) > 0 {
|
||||||
|
existing.SANs = patch.SANs
|
||||||
|
}
|
||||||
|
if patch.Environment != "" {
|
||||||
|
existing.Environment = patch.Environment
|
||||||
|
}
|
||||||
|
if patch.OwnerID != "" {
|
||||||
|
existing.OwnerID = patch.OwnerID
|
||||||
|
}
|
||||||
|
if patch.TeamID != "" {
|
||||||
|
existing.TeamID = patch.TeamID
|
||||||
|
}
|
||||||
|
if patch.IssuerID != "" {
|
||||||
|
existing.IssuerID = patch.IssuerID
|
||||||
|
}
|
||||||
|
if patch.RenewalPolicyID != "" {
|
||||||
|
existing.RenewalPolicyID = patch.RenewalPolicyID
|
||||||
|
}
|
||||||
|
if patch.CertificateProfileID != "" {
|
||||||
|
existing.CertificateProfileID = patch.CertificateProfileID
|
||||||
|
}
|
||||||
|
if patch.Status != "" {
|
||||||
|
existing.Status = patch.Status
|
||||||
|
}
|
||||||
|
if patch.Tags != nil {
|
||||||
|
existing.Tags = patch.Tags
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := s.certRepo.Update(ctx, existing); err != nil {
|
||||||
return nil, fmt.Errorf("failed to update certificate: %w", err)
|
return nil, fmt.Errorf("failed to update certificate: %w", err)
|
||||||
}
|
}
|
||||||
return &cert, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveCertificate marks a certificate as archived (handler interface method).
|
// ArchiveCertificate marks a certificate as archived (handler interface method).
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ func (s *DeploymentService) CreateDeploymentJobs(ctx context.Context, certID str
|
|||||||
if target.ID != "" {
|
if target.ID != "" {
|
||||||
job.TargetID = &target.ID
|
job.TargetID = &target.ID
|
||||||
}
|
}
|
||||||
|
// Route job to the target's assigned agent
|
||||||
|
if target.AgentID != "" {
|
||||||
|
agentID := target.AgentID
|
||||||
|
job.AgentID = &agentID
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.jobRepo.Create(ctx, job); err != nil {
|
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||||
slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err)
|
slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err)
|
||||||
|
|||||||
@@ -85,6 +85,45 @@ func TestDeploymentService_CreateDeploymentJobs_Success(t *testing.T) {
|
|||||||
if job.TargetID == nil || len(*job.TargetID) == 0 {
|
if job.TargetID == nil || len(*job.TargetID) == 0 {
|
||||||
t.Errorf("expected job to have TargetID set")
|
t.Errorf("expected job to have TargetID set")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M31: Verify AgentID is set from target's agent assignment
|
||||||
|
if job.AgentID == nil {
|
||||||
|
t.Errorf("expected job to have AgentID set (M31 agent routing)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeploymentService_CreateDeploymentJobs_SetsAgentID verifies AgentID is populated from target.
|
||||||
|
func TestDeploymentService_CreateDeploymentJobs_SetsAgentID(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, jobRepo, targetRepo, _, _, _, _ := newTestDeploymentService()
|
||||||
|
|
||||||
|
target := &domain.DeploymentTarget{
|
||||||
|
ID: "tgt-nginx-1",
|
||||||
|
Name: "NGINX Server 1",
|
||||||
|
Type: domain.TargetTypeNGINX,
|
||||||
|
AgentID: "agent-web-01",
|
||||||
|
Enabled: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
targetRepo.AddTarget(target)
|
||||||
|
|
||||||
|
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDeploymentJobs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(jobIDs) != 1 {
|
||||||
|
t.Fatalf("expected 1 job, got %d", len(jobIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
job := jobRepo.Jobs[jobIDs[0]]
|
||||||
|
if job.AgentID == nil {
|
||||||
|
t.Fatal("expected AgentID to be set on deployment job")
|
||||||
|
}
|
||||||
|
if *job.AgentID != "agent-web-01" {
|
||||||
|
t.Errorf("expected AgentID 'agent-web-01', got '%s'", *job.AgentID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DigestService generates and sends periodic certificate digest emails.
|
||||||
|
// It aggregates statistics from StatsService and sends HTML-formatted
|
||||||
|
// summary emails to configured recipients.
|
||||||
|
type DigestService struct {
|
||||||
|
statsService *StatsService
|
||||||
|
certRepo repository.CertificateRepository
|
||||||
|
ownerRepo repository.OwnerRepository
|
||||||
|
emailSender HTMLEmailSender
|
||||||
|
recipients []string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMLEmailSender defines the interface for sending HTML emails.
|
||||||
|
// Implemented by the email notifier adapter.
|
||||||
|
type HTMLEmailSender interface {
|
||||||
|
SendHTML(ctx context.Context, recipient string, subject string, htmlBody string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DigestData holds the aggregated data for a digest email.
|
||||||
|
type DigestData struct {
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
TotalCertificates int64 `json:"total_certificates"`
|
||||||
|
ExpiringCertificates int64 `json:"expiring_certificates"`
|
||||||
|
ExpiredCertificates int64 `json:"expired_certificates"`
|
||||||
|
RevokedCertificates int64 `json:"revoked_certificates"`
|
||||||
|
ActiveAgents int64 `json:"active_agents"`
|
||||||
|
OfflineAgents int64 `json:"offline_agents"`
|
||||||
|
TotalAgents int64 `json:"total_agents"`
|
||||||
|
PendingJobs int64 `json:"pending_jobs"`
|
||||||
|
FailedJobs int64 `json:"failed_jobs"`
|
||||||
|
CompletedJobs int64 `json:"completed_jobs"`
|
||||||
|
ExpiringCerts []DigestCertEntry `json:"expiring_certs"`
|
||||||
|
RecentFailures []DigestJobEntry `json:"recent_failures"`
|
||||||
|
StatusCounts []DigestStatusCount `json:"status_counts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DigestCertEntry represents a certificate entry in the digest.
|
||||||
|
type DigestCertEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CommonName string `json:"common_name"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
DaysLeft int `json:"days_left"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DigestJobEntry represents a failed job entry in the digest.
|
||||||
|
type DigestJobEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CertificateID string `json:"certificate_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DigestStatusCount represents certificate counts by status for the digest.
|
||||||
|
type DigestStatusCount struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDigestService creates a new digest service.
|
||||||
|
func NewDigestService(
|
||||||
|
statsService *StatsService,
|
||||||
|
certRepo repository.CertificateRepository,
|
||||||
|
ownerRepo repository.OwnerRepository,
|
||||||
|
emailSender HTMLEmailSender,
|
||||||
|
recipients []string,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) *DigestService {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &DigestService{
|
||||||
|
statsService: statsService,
|
||||||
|
certRepo: certRepo,
|
||||||
|
ownerRepo: ownerRepo,
|
||||||
|
emailSender: emailSender,
|
||||||
|
recipients: recipients,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateDigest aggregates current system statistics into a DigestData struct.
|
||||||
|
func (s *DigestService) GenerateDigest(ctx context.Context) (*DigestData, error) {
|
||||||
|
digest := &DigestData{
|
||||||
|
GeneratedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get dashboard summary
|
||||||
|
summaryRaw, err := s.statsService.GetDashboardSummary(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get dashboard summary: %w", err)
|
||||||
|
}
|
||||||
|
if summary, ok := summaryRaw.(*DashboardSummary); ok {
|
||||||
|
digest.TotalCertificates = summary.TotalCertificates
|
||||||
|
digest.ExpiringCertificates = summary.ExpiringCertificates
|
||||||
|
digest.ExpiredCertificates = summary.ExpiredCertificates
|
||||||
|
digest.RevokedCertificates = summary.RevokedCertificates
|
||||||
|
digest.ActiveAgents = summary.ActiveAgents
|
||||||
|
digest.OfflineAgents = summary.OfflineAgents
|
||||||
|
digest.TotalAgents = summary.TotalAgents
|
||||||
|
digest.PendingJobs = summary.PendingJobs
|
||||||
|
digest.FailedJobs = summary.FailedJobs
|
||||||
|
digest.CompletedJobs = summary.CompleteJobs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get certificates by status
|
||||||
|
statusRaw, err := s.statsService.GetCertificatesByStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("failed to get status counts for digest", "error", err)
|
||||||
|
} else if counts, ok := statusRaw.([]CertificateStatusCount); ok {
|
||||||
|
for _, c := range counts {
|
||||||
|
digest.StatusCounts = append(digest.StatusCounts, DigestStatusCount(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get expiring certificates (next 30 days)
|
||||||
|
now := time.Now()
|
||||||
|
thirtyDaysFromNow := now.AddDate(0, 0, 30)
|
||||||
|
allCerts, _, err := s.certRepo.List(ctx, &repository.CertificateFilter{Page: 1, PerPage: 10000})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("failed to list certs for digest", "error", err)
|
||||||
|
} else {
|
||||||
|
for _, cert := range allCerts {
|
||||||
|
if !cert.ExpiresAt.IsZero() && cert.ExpiresAt.After(now) && cert.ExpiresAt.Before(thirtyDaysFromNow) {
|
||||||
|
daysLeft := int(time.Until(cert.ExpiresAt).Hours() / 24)
|
||||||
|
digest.ExpiringCerts = append(digest.ExpiringCerts, DigestCertEntry{
|
||||||
|
ID: cert.ID,
|
||||||
|
CommonName: cert.CommonName,
|
||||||
|
ExpiresAt: cert.ExpiresAt,
|
||||||
|
DaysLeft: daysLeft,
|
||||||
|
OwnerID: cert.OwnerID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return digest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDigest generates a digest and sends it to all configured recipients.
|
||||||
|
func (s *DigestService) SendDigest(ctx context.Context) error {
|
||||||
|
if s.emailSender == nil {
|
||||||
|
return fmt.Errorf("email sender not configured — set CERTCTL_SMTP_HOST and CERTCTL_SMTP_FROM_ADDRESS")
|
||||||
|
}
|
||||||
|
|
||||||
|
digest, err := s.GenerateDigest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate digest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlBody, err := s.RenderDigestHTML(digest)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to render digest HTML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := fmt.Sprintf("certctl Certificate Digest — %s", digest.GeneratedAt.Format("2006-01-02"))
|
||||||
|
|
||||||
|
recipients := s.recipients
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
// Fall back to owner emails
|
||||||
|
recipients = s.resolveOwnerEmails(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
s.logger.Warn("no digest recipients configured and no owner emails found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendErrors int
|
||||||
|
for _, recipient := range recipients {
|
||||||
|
if err := s.emailSender.SendHTML(ctx, recipient, subject, htmlBody); err != nil {
|
||||||
|
s.logger.Error("failed to send digest to recipient",
|
||||||
|
"recipient", recipient,
|
||||||
|
"error", err)
|
||||||
|
sendErrors++
|
||||||
|
} else {
|
||||||
|
s.logger.Info("digest email sent", "recipient", recipient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sendErrors > 0 {
|
||||||
|
return fmt.Errorf("failed to send digest to %d of %d recipients", sendErrors, len(recipients))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessDigest is the scheduler-facing method. It generates and sends the digest,
|
||||||
|
// logging errors rather than propagating them to match the scheduler pattern.
|
||||||
|
func (s *DigestService) ProcessDigest(ctx context.Context) error {
|
||||||
|
return s.SendDigest(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderDigestHTML renders the digest data into an HTML email body.
|
||||||
|
func (s *DigestService) RenderDigestHTML(data *DigestData) (string, error) {
|
||||||
|
tmpl, err := template.New("digest").Parse(digestHTMLTemplate)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse digest template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to execute digest template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewDigest generates and renders a digest without sending it.
|
||||||
|
// Used by the API handler for preview endpoints.
|
||||||
|
func (s *DigestService) PreviewDigest(ctx context.Context) (string, error) {
|
||||||
|
digest, err := s.GenerateDigest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate digest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.RenderDigestHTML(digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOwnerEmails collects unique email addresses from all certificate owners.
|
||||||
|
func (s *DigestService) resolveOwnerEmails(ctx context.Context) []string {
|
||||||
|
if s.ownerRepo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
owners, err := s.ownerRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("failed to list owners for digest recipients", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var emails []string
|
||||||
|
for _, owner := range owners {
|
||||||
|
if owner.Email != "" && !seen[owner.Email] {
|
||||||
|
seen[owner.Email] = true
|
||||||
|
emails = append(emails, owner.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return emails
|
||||||
|
}
|
||||||
|
|
||||||
|
// digestHTMLTemplate is the HTML template for the certificate digest email.
|
||||||
|
const digestHTMLTemplate = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>certctl Certificate Digest</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background: #f5f5f5; color: #333; }
|
||||||
|
.container { max-width: 640px; margin: 0 auto; background: #fff; }
|
||||||
|
.header { background: #1a1a2e; color: #fff; padding: 24px 32px; }
|
||||||
|
.header h1 { margin: 0; font-size: 22px; font-weight: 600; }
|
||||||
|
.header .date { color: #a0a0b0; font-size: 13px; margin-top: 4px; }
|
||||||
|
.section { padding: 24px 32px; border-bottom: 1px solid #eee; }
|
||||||
|
.section h2 { font-size: 16px; font-weight: 600; margin: 0 0 16px 0; color: #1a1a2e; }
|
||||||
|
.stats-grid { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.stat-card { flex: 1; min-width: 120px; background: #f8f9fa; border-radius: 8px; padding: 16px; text-align: center; }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 700; color: #1a1a2e; }
|
||||||
|
.stat-label { font-size: 12px; color: #666; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.stat-warn .stat-value { color: #e67e22; }
|
||||||
|
.stat-danger .stat-value { color: #e74c3c; }
|
||||||
|
.stat-success .stat-value { color: #27ae60; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th { text-align: left; padding: 8px 12px; background: #f8f9fa; color: #666; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||||
|
.badge-warn { background: #fef3e2; color: #e67e22; }
|
||||||
|
.badge-danger { background: #fde8e8; color: #e74c3c; }
|
||||||
|
.badge-ok { background: #e8f8ef; color: #27ae60; }
|
||||||
|
.footer { padding: 20px 32px; text-align: center; color: #999; font-size: 12px; background: #f8f9fa; }
|
||||||
|
.empty-state { text-align: center; padding: 24px; color: #999; font-size: 14px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>certctl Certificate Digest</h1>
|
||||||
|
<div class="date">Generated: {{.GeneratedAt.Format "January 2, 2006 3:04 PM"}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>System Overview</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{.TotalCertificates}}</div>
|
||||||
|
<div class="stat-label">Total Certs</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-warn">
|
||||||
|
<div class="stat-value">{{.ExpiringCertificates}}</div>
|
||||||
|
<div class="stat-label">Expiring</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-danger">
|
||||||
|
<div class="stat-value">{{.ExpiredCertificates}}</div>
|
||||||
|
<div class="stat-label">Expired</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-success">
|
||||||
|
<div class="stat-value">{{.ActiveAgents}}</div>
|
||||||
|
<div class="stat-label">Active Agents</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Jobs Summary</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{.PendingJobs}}</div>
|
||||||
|
<div class="stat-label">Pending</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-danger">
|
||||||
|
<div class="stat-value">{{.FailedJobs}}</div>
|
||||||
|
<div class="stat-label">Failed</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-success">
|
||||||
|
<div class="stat-value">{{.CompletedJobs}}</div>
|
||||||
|
<div class="stat-label">Completed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .ExpiringCerts}}
|
||||||
|
<div class="section">
|
||||||
|
<h2>Certificates Expiring Soon</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Common Name</th><th>Expires</th><th>Days Left</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .ExpiringCerts}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.CommonName}}</td>
|
||||||
|
<td>{{.ExpiresAt.Format "Jan 2, 2006"}}</td>
|
||||||
|
<td>
|
||||||
|
{{if le .DaysLeft 7}}<span class="badge badge-danger">{{.DaysLeft}} days</span>
|
||||||
|
{{else if le .DaysLeft 14}}<span class="badge badge-warn">{{.DaysLeft}} days</span>
|
||||||
|
{{else}}<span class="badge badge-ok">{{.DaysLeft}} days</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="section">
|
||||||
|
<h2>Certificates Expiring Soon</h2>
|
||||||
|
<div class="empty-state">No certificates expiring in the next 30 days.</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
This digest was automatically generated by certctl.<br>
|
||||||
|
Configure digest settings with CERTCTL_DIGEST_* environment variables.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user