feat(pre-2.1.0): demo data overhaul, examples, migration guides, install script

Pre-2.1.0 adoption polish delivering all four milestones:

A) Demo Data Overhaul — seed_demo.sql rewritten with 35 certs across
   5 issuers, 8 agents, 8 targets, 50+ jobs spanning 90 days, 55+
   audit events, discovery scans, network scan targets, S/MIME cert.

B) Examples Directory — 5 turnkey docker-compose configs:
   acme-nginx, acme-wildcard-dns01, private-ca-traefik,
   step-ca-haproxy, multi-issuer.

C) Migration Guides — migrate-from-certbot.md,
   migrate-from-acmesh.md, certctl-for-cert-manager-users.md.

D) Agent Install Script — install-agent.sh with cross-platform
   support (Linux systemd + macOS launchd), release.yml updated
   for 6-target cross-compilation.

Triple-audited against codebase: 22 factual corrections applied
across docs, examples, and config (env var names, CLI flags, ports,
DNS hook interface, scheduler loop counts, license conversion date).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-29 18:26:58 -04:00
parent 5f81de3219
commit bcf2c3ae92
29 changed files with 4508 additions and 214 deletions
+242
View File
@@ -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)
+150
View File
@@ -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