mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 12:19:05 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user