#!/usr/bin/env bash set -Eeuo pipefail # Provision a CertLocker-held certificate private key, build a CSR from that # key, and use stock Certbot CSR mode against CertLocker or another ACME directory. # Secret values are read from files/environment and are never printed. umask 077 required_env() { local name="$1" if [ -z "${!name:-}" ]; then echo "[certlocker-certbot] Missing required environment variable: ${name}" >&2 exit 2 fi } required_env CERTLOCKER_ACME_DIRECTORY required_env CERTLOCKER_API_BASE required_env CERTLOCKER_TOKEN_FILE required_env CERTLOCKER_DOMAIN CERTBOT_BIN="${CERTBOT_BIN:-certbot}" CERTLOCKER_API_BASE="${CERTLOCKER_API_BASE%/}" CERTLOCKER_OUTPUT_DIR="${CERTLOCKER_OUTPUT_DIR:-/etc/letsencrypt/live/${CERTLOCKER_DOMAIN}}" CERTLOCKER_STATE_DIR="${CERTLOCKER_STATE_DIR:-/etc/letsencrypt-certlocker/${CERTLOCKER_DOMAIN}}" CERTLOCKER_WORK_DIR="${CERTLOCKER_WORK_DIR:-/var/lib/letsencrypt-certlocker/${CERTLOCKER_DOMAIN}}" CERTLOCKER_USER_AGENT="${CERTLOCKER_USER_AGENT:-Mozilla/5.0 CertLocker-Key-Provisioner}" CERTLOCKER_CERTBOT_AUTHENTICATOR="${CERTLOCKER_CERTBOT_AUTHENTICATOR:-standalone}" case "$CERTLOCKER_ACME_DIRECTORY" in http://*|https://*) ;; *) echo "[certlocker-certbot] CERTLOCKER_ACME_DIRECTORY must be an HTTP(S) URL" >&2; exit 2 ;; esac case "$CERTLOCKER_API_BASE" in http://*|https://*) ;; *) echo "[certlocker-certbot] CERTLOCKER_API_BASE must be an HTTP(S) URL" >&2; exit 2 ;; esac if [[ ! "$CERTLOCKER_DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]]; then echo "[certlocker-certbot] CERTLOCKER_DOMAIN contains unsupported characters" >&2 exit 2 fi if [ ! -r "$CERTLOCKER_TOKEN_FILE" ]; then echo "[certlocker-certbot] Token file is not readable: ${CERTLOCKER_TOKEN_FILE}" >&2 exit 2 fi token_mode="$(stat -c '%a' "$CERTLOCKER_TOKEN_FILE")" token_mode_value=$((8#$token_mode)) if (( (token_mode_value & 077) != 0 )); then echo "[certlocker-certbot] Token file must not be group/world accessible (expected mode 0600 or stricter)" >&2 exit 2 fi token="$(tr -d '\r\n' < "$CERTLOCKER_TOKEN_FILE")" if [[ ! "$token" =~ ^[A-Za-z0-9_.-]+$ ]]; then echo "[certlocker-certbot] Token file is empty or contains unsupported characters" >&2 exit 2 fi CERTLOCKER_ACME_CONTACT_EMAIL="${CERTLOCKER_ACME_CONTACT_EMAIL:-${token}@certlocker.io}" for command_name in "$CERTBOT_BIN" awk chmod curl grep install mktemp openssl rm sha256sum stat tr; do if ! command -v "$command_name" >/dev/null 2>&1; then echo "[certlocker-certbot] Required command not found: ${command_name}" >&2 exit 2 fi done install -d -m 0700 "$CERTLOCKER_OUTPUT_DIR" "$CERTLOCKER_STATE_DIR" "$CERTLOCKER_WORK_DIR" temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/certlocker-certbot.XXXXXX")" cleanup() { rm -rf -- "$temp_dir" } trap cleanup EXIT bundle_file="$temp_dir/provisioned-bundle.pem" private_key_file="$temp_dir/private-key.pem" csr_file="$temp_dir/request.csr" logs_dir="$temp_dir/logs" install -d -m 0700 "$logs_dir" curl --fail-with-body --silent --show-error \ -A "$CERTLOCKER_USER_AGENT" \ -H "x-api-token: ${token}" \ -o "$bundle_file" \ "${CERTLOCKER_API_BASE}/v1/cert/fetch-bundle/${token}" grep -q -- '-----BEGIN .*PRIVATE KEY-----' "$bundle_file" grep -q -- '-----BEGIN CERTIFICATE-----' "$bundle_file" openssl pkey -in "$bundle_file" -out "$private_key_file" install -m 0600 "$private_key_file" "$CERTLOCKER_OUTPUT_DIR/privkey.pem" openssl req -new \ -key "$CERTLOCKER_OUTPUT_DIR/privkey.pem" \ -out "$csr_file" \ -subj "/CN=${CERTLOCKER_DOMAIN}" \ -addext "subjectAltName=DNS:${CERTLOCKER_DOMAIN}" for output_name in cert.pem chain.pem fullchain.pem; do output_path="$CERTLOCKER_OUTPUT_DIR/$output_name" if [ -e "$output_path" ]; then rm -f -- "$output_path" fi done certbot_args=( certonly --non-interactive --agree-tos --quiet --email "$CERTLOCKER_ACME_CONTACT_EMAIL" --server "$CERTLOCKER_ACME_DIRECTORY" --config-dir "$CERTLOCKER_STATE_DIR" --work-dir "$CERTLOCKER_WORK_DIR" --logs-dir "$logs_dir" --csr "$csr_file" --cert-path "$CERTLOCKER_OUTPUT_DIR/cert.pem" --chain-path "$CERTLOCKER_OUTPUT_DIR/chain.pem" --fullchain-path "$CERTLOCKER_OUTPUT_DIR/fullchain.pem" ) case "$CERTLOCKER_CERTBOT_AUTHENTICATOR" in standalone) certbot_args+=(--standalone) ;; dns-cloudflare) required_env CERTLOCKER_CLOUDFLARE_CREDENTIALS if [ ! -r "$CERTLOCKER_CLOUDFLARE_CREDENTIALS" ]; then echo "[certlocker-certbot] Cloudflare credentials file is not readable" >&2 exit 2 fi cloudflare_mode="$(stat -c '%a' "$CERTLOCKER_CLOUDFLARE_CREDENTIALS")" cloudflare_mode_value=$((8#$cloudflare_mode)) if (( (cloudflare_mode_value & 077) != 0 )); then echo "[certlocker-certbot] Cloudflare credentials must not be group/world accessible" >&2 exit 2 fi certbot_args+=( --dns-cloudflare --dns-cloudflare-credentials "$CERTLOCKER_CLOUDFLARE_CREDENTIALS" --dns-cloudflare-propagation-seconds "${CERTLOCKER_DNS_PROPAGATION_SECONDS:-20}" ) ;; *) echo "[certlocker-certbot] Unsupported authenticator: ${CERTLOCKER_CERTBOT_AUTHENTICATOR}" >&2 exit 2 ;; esac if [ "${CERTLOCKER_CERTBOT_INSECURE:-false}" = "true" ]; then certbot_args+=(--no-verify-ssl) fi "$CERTBOT_BIN" "${certbot_args[@]}" key_hash="$( openssl pkey -in "$CERTLOCKER_OUTPUT_DIR/privkey.pem" -pubout -outform DER 2>/dev/null | sha256sum | awk '{print $1}' )" cert_hash="$( openssl x509 -in "$CERTLOCKER_OUTPUT_DIR/cert.pem" -pubkey -noout 2>/dev/null | openssl pkey -pubin -outform DER 2>/dev/null | sha256sum | awk '{print $1}' )" if [ "$key_hash" != "$cert_hash" ]; then echo "[certlocker-certbot] Downloaded certificate does not match the provisioned private key" >&2 exit 1 fi openssl x509 -in "$CERTLOCKER_OUTPUT_DIR/cert.pem" -checkhost "$CERTLOCKER_DOMAIN" -noout >/dev/null chmod 0600 "$CERTLOCKER_OUTPUT_DIR/privkey.pem" for public_file in cert.pem chain.pem fullchain.pem; do if [ -e "$CERTLOCKER_OUTPUT_DIR/$public_file" ]; then chmod 0644 "$CERTLOCKER_OUTPUT_DIR/$public_file" fi done echo "[certlocker-certbot] Success: certificate, private key, and SAN match for ${CERTLOCKER_DOMAIN}"