#!/bin/sh -ef
#- https://github.com/lauriro/certx.sh | https://certx.sh/MIT-LICENSE.txt
#- certx.sh - v26.2.9 - Simple ACME client for green certificates.
#-
# Install:
# curl -JO certx.sh
# chmod +x certx.sh
# ./certx.sh
#-
#- Commands:
#- domain - list configured domains
#- domain [name] [dns|dns-persist|http] [opts].. - configure domain validation
#- domain [name] drop - remove domain configuration
#- ip - list configured IPs
#- ip [addr] http [opts].. - configure IP validation
#- ip [addr] drop - remove IP configuration
#- cert - list created certificates
#- cert [name] [domain,..] [profile] - configure cert domains and optional CA profile (eg. shortlived)
#- cert [name] [key_path|crt_path] [paths,..]
#- cert [name] post_hook [cmd] - commands to run after cert deployment
#- cert [name] chain [N] - set alternate cert positional index (1-..)
#- cert [name] order - order and deploy named cert
#- cert [name] revoke [reason] - revoke certificate (reason: 0-10, default: 0)
#- cert [name] drop - remove cert configuration
#- account-rollover - change account key
#- account-deactivate - deactivate account
#- authz-deactivate [url] - deactivate authorization
#- ca-reset - delete all CA/account configuration
#- renew-all [days|%] - renew via ARI or days/% of validity (default: ARI, 20%)
#- retry [order-file] - retry failed order
#- help [topic]
#-
#- Examples:
#- ./certx.sh domain example.com dns cloudflare YOUR-API-TOKEN
#- ./certx.sh domain example.com dns-persist wildcard
#- ./certx.sh cert mycert 'example.com,*.example.com'
#- ./certx.sh cert mycert 'example.com,203.0.113.1' shortlived
#- ./certx.sh cert mycert order
#-
#ca-
#ca- CA Directory URLs:
#ca- LetsEncrypt Test: https://acme-staging-v02.api.letsencrypt.org/directory
#ca- LetsEncrypt Live: https://acme-v02.api.letsencrypt.org/directory
#ca- Google Test: https://dv.acme-v02.test-api.pki.goog/directory
#ca- Google Live: https://dv.acme-v02.api.pki.goog/directory
#ca- ZeroSSL Live: https://acme.zerossl.com/v2/DV90
#ca-
#dns-
#dns- Automated DNS validation requires executable script: ./dns-PROVIDER.sh
#dns- Script adds TXT record and outputs cleanup commands to stdout.
#dns-
#dns- Available providers: cloudflare, digitalocean, linode, zone.eu
#dns- curl -O certx.sh/dns-PROVIDER.sh && chmod +x dns-PROVIDER.sh
#dns-
#eab-
#eab- Request External Account Binding (EAB) credentials:
#eab- Google: gcloud publicca external-account-keys create
#eab- - run locally or in cloud shell web https://console.cloud.google.com/welcome?cloudshell=true
#eab- ZeroSSL: curl --data 'email=your@email.com' https://api.zerossl.com/acme/eab-credentials-email
#eab-
#domain-
#domain- Configure domain/ip validation before ordering certificates.
#domain-
#domain- Examples:
#domain- ./certx.sh domain example.com dns manual # Interactive (you add TXT record)
#domain- ./certx.sh domain example.com dns cloudflare TOKEN # Automated via ./dns-cloudflare.sh script
#domain- ./certx.sh domain example.com http /www # Creates HTTP challenge file to /www/.well-known/acme-challenge/
#domain- ./certx.sh ip 203.0.113.1 http ssh://203.0.113.1/www # IP identifiers only support http-01 validation
#domain-
#order-
#order- In case of error "Could not validate ARI 'replaces' field" - try second time again, 'replaces' is used once.
#order-
: "${CERTX_CONF:="./certx.conf"} ${CERTX_LOG:="./certx-$(date +%Y-%m).log"} ${CERTX_PID:=$$}"
umask 077
export LC_ALL=C UA='certx.sh/26.2.9' CERTX_CONF CERTX_LOG
NOW=$(date +%s) ARI='' KID='' NONCE='' NL='
'
usage() {
sed -n "/^#$1- \{0,1\}/s,,,p" "$0" >&2
}
log() {
printf '%s\n' "$3$1" >&2
printf '%s [%s] %s -- %s\n' "$(date +%Y-%m-%d\ %H:%M:%S)" "$CERTX_PID" "${SUDO_USER-$USER}" "$1" >>"$CERTX_LOG"
[ -z "$2" ] || usage "$2"
}
die() {
log "ERROR: $1" "$2" "$NL"
exit 1
}
has() {
for cmd; do CMD=$cmd; command -v "$cmd" >/dev/null || return 1; done
}
ask() {
printf '\n%b: ' "$1" >&2
read -r R && [ -n "$R" ] && printf %s "$R"
}
_conf() {
sed -n "/^$(printf %s "$1" | sed 's/[][\\.^$*]/\\&/g')$3 *= */$2" "$CERTX_CONF"
}
conf_has() {
_VAL=$(_conf "$1" '!b;s,,,p;q')
[ -n "$_VAL" ]
}
conf_get() {
conf_has "$1" && printf '%s\n' "$_VAL"
}
conf_set() {
REST="$(_conf "$1" '!p' "$3")"
printf '%s\n' "${2:+"$1 = $2"}" "$REST" | sort | sed '/^$/d' >"$CERTX_CONF"
}
conf_find() {
_conf "$1" "!b;s,,$3\1 = ,p" " \([^ ]*\) $2"
}
conf_ask() {
conf_has "$1" || conf_set "$1" "$(ask "$2")"
}
b64url() {
openssl base64 | tr '/+' '_-' | tr -d '=\n'
}
b64dec() {
printf "%s%$(((4-${#1}%4)%4))s\n" "$1" '' | tr '_ -' '/=+' | openssl base64 -d
}
shaB64() {
printf %s "$1" | openssl sha256 -binary | b64url
}
hexB64() {
# shellcheck disable=SC2046 # Intentionally split
printf %b "$(printf '\\%03o' $(sed 's/../0x& /g'))" | b64url
}
json() { # [key] [file] [section-matcher]
_VAL=$(tr -d '\011\n ' <"${2:-_dir}" | sed 's/{/\n{/g' | sed -n "/${3:-.}/p" | sed -n 's/.*"'"$1"'":\("[^"]\{1,\}"\|\[[^]]\{1,\}\]\|[[:alnum:]]*\).*/\1/p' | sed 's/","/\n/g;s/[]["]//g')
[ -n "$_VAL" ] && printf '%s\n' "$_VAL"
}
sign() { # [URL] [PAYLOAD] [JWK] [KEY]
PROT=$(printf '{"alg":"ES256",%s,"url":"%s"}' "${3:-"$KID"}$5" "$1" | b64url)
DATA=$(printf %s "$2" | b64url)
# shellcheck disable=SC2046 # Intentionally split
SIG=$(printf %64s $(printf %s.%s "$PROT" "$DATA" | openssl sha256 -sign "${4:-_key}" | openssl asn1parse -inform der | cut -d: -f4) | tr ' ' 0 | hexB64)
printf '{"protected":"%s","payload":"%s","signature":"%s"}' "$PROT" "$DATA" "$SIG"
}
req() {
[ $# -gt 1 ] && {
[ -n "$NONCE" ] || req "$(json newNonce)" >_res || die 'Cannot get Nonce'
set -- -H 'Content-Type: application/jose+json' -d "$(sign "$1" "$2" "$3" "$4" ',"nonce":"'"$NONCE"'"')" "$1"
}
RES=$(curl -si -A "$UA" --retry 10 --retry-connrefused "$@" | sed 's/[[:space:]]*$//')
NONCE=$(printf %s "$RES" | sed -n 's/replay-nonce: *//pi')
CODE=$(printf %s "${RES#* }500" | head -n1)
[ "${CODE%% *}" -lt 300 ] && printf '%s\n' "$RES" || { printf '%s\n' "$RES" >&2; false; }
}
create_key() {
log "Creating key '$2'"
openssl ecparam -genkey -name prime256v1 -noout >"$2"
conf_set "$1" "$(openssl ec -in "$2" -no_public -conv_form compressed -outform DER 2>/dev/null | b64url)"
}
# Expand compressed key from config or create a new one
expand_key() {
b64dec "$(conf_get "$1")" | openssl ec -inform DER 2>/dev/null >"$2" || create_key "$@"
}
jwk() {
openssl ec -in "$1" -pubout -outform DER 2>/dev/null >_pub
printf '{"crv":"P-256","kty":"EC","x":"%s","y":"%s"}' "$(tail -c64 _pub | head -c32 | b64url)" "$(tail -c32 _pub | b64url)"
}
get_kid() {
[ -z "$KID" ] || return
req "$CA" >_dir || die "Cannot get CA: $CA"
log "CA: $CA"
conf_ask _terms "CA Terms of Service: $(json termsOfService)\nAccept? (type YES)"
expand_key _key _key
conf_has _kid || {
log 'Registering account'
JWK=$(jwk _key)
EMAIL=$(conf_get _email) && EMAIL=',"contact":["mailto:'"$EMAIL"'"]' ||:
EAB=''
[ "$(json externalAccountRequired)" = "true" ] && {
log 'External Account Binding required!' eab
EKEY=$(ask 'EAB key ID')
EMAC=$(ask 'EAB HMAC')
PROTECTED=$(printf '{"alg":"HS256","kid":"%s","url":"%s"}' "$EKEY" "$(json newAccount)" | b64url)
PAYLOAD=$(printf %s "$JWK" | b64url)
HEX=$(b64dec "$EMAC" | od -An -tx1 | tr -d ' \n')
SIG=$(printf %s.%s "$PROTECTED" "$PAYLOAD" | openssl mac -digest sha256 -macopt "hexkey:$HEX" -binary HMAC | b64url)
EAB=',"externalAccountBinding":{"protected":"'$PROTECTED'","payload":"'$PAYLOAD'","signature":"'$SIG'"}'
}
req "$(json newAccount)" '{"termsOfServiceAgreed":true'"${EMAIL}${EAB}}" '"jwk":'"$JWK" >_res || die 'Registration failed'
conf_set _kid "$(sed -n 's/^location: *//pi' _res)"
conf_set _jwk "$JWK"
conf_set _thumb "$(shaB64 "$JWK")"
}
ARI=$(json renewalInfo ||:)
KID='"kid":"'$(conf_get _kid)'"'
}
cleanup() {
[ -s _cleanup ] || return 0
log 'Cleanup challenges'
sh _cleanup || log 'Warning: Cleanup failed'
:>_cleanup
}
seconds_to() {
T=$1 && [ -n "$T" ] && {
[ "$T" -gt 0 ] || T=$(($(date -d"$T" +%s || date -jf'%b %d %T %Y %Z' "$T" +%s || date -jf'%Y-%m-%dT%H:%M:%SZ' "$T" +%s)-NOW))
} 2>/dev/null && printf '%s\n' "$T"
}
deploy_file() {
for TARGET in $2; do
log "Deploying $1 to $TARGET"
P=${TARGET#*://}
# shellcheck disable=SC2029 # Client-side expansion of path is intended
case "$TARGET" in
ssh://*)
ssh "${P%%/*}" "cat > '/${P#*/}'" <"$1"
[ -z "$3" ] || printf 'ssh "%s" "rm %s"\n' "${P%%/*}" "'/${P#*/}'" >>_cleanup
;;
ftps?://*)
curl -sS -T "$1" "$TARGET"
[ -z "$3" ] || printf 'curl -sS "%s://%s" -Q "DELE /%s"\n' "${TARGET%%://*}" "${P%%/*}" "${P#*/}" >>_cleanup
;;
file://*|/*)
cat "$1" >"$P"
[ -z "$3" ] || printf 'rm %s\n' "'$P'" >>_cleanup
;;
*) false;;
esac || die 'Deploy failed'
done
}
dns_query() {
if has dig; then
dig +short "$1" "$2" ${3:+"@$3"} | cut -d' ' -f1
elif has host; then
host -t "$1" "$2" ${3:+"$3"} | cut -d' ' -f$((4+($#<3)))
fi 2>/dev/null | sed -n "/${4:-.}/p"
}
wait_dns() {
has dig || has host || {
log 'WARNING: dig/host not found, sleeping 120s without verification'
sleep 120
return 0
}
log "Waiting for DNS propagation $2"
SOA=$(dns_query SOA "$1")
for i in $(seq 1 150); do
[ -n "$(dns_query TXT "$2" "$SOA" "$3")" ] && { log " OK ($((i*2))s)"; return 0; }
sleep 2; printf '.'
done >&2
die "DNS propagation timeout $2"
}
get_domain() {
NAME=$1
TYPE=$2 && conf_has "ip $1" && TYPE=$3 || while ! conf_has "domain $NAME"; do
[ "$NAME" = "${NAME#*.}" ] && die "No domain/ip config for '$1'" domain
NAME=${NAME#*.}
done
printf '%s\n' "$NAME"
}
challenge() {
NAME=$(json value _auth) || die 'No identifier in authorization'
DOMAIN=$(get_domain "$NAME")
log "Authorization $NAME: $1"
# shellcheck disable=SC2046 # Intentionally split into positional params
set -- $(conf_get "domain $DOMAIN" || conf_get "ip $DOMAIN")
case "$1" in
dns-persist)
RR="_validation-persist.$NAME"
VAL=$(conf_get "domain $DOMAIN persist") || {
VAL=$(json issuer-domain-names _auth '"type":"dns-persist-01"') || die 'CA do not support dns-persist'
VAL="${VAL%%$NL*}; accounturi=$(conf_get _kid)${2:+"; policy=wildcard"}${3:+"; persistUntil=$3"}"
printf 'Add DNS record: %s TXT="%s"\nDone? ' "$RR" "$VAL"
read -r _
conf_set "domain $DOMAIN persist" "$VAL"
}
wait_dns "$DOMAIN" "$RR" "${VAL%%;*}"
;;
*)
RR="_acme-challenge.$NAME"
TOK=$(json token _auth '"type":"'"$1"'-01"') || die 'No challenge token'
THUMB=$(conf_get _thumb)
VAL=$(shaB64 "$TOK.$THUMB")
;;
esac
case "$1.$2" in
dns.manual)
printf 'Add DNS record: %s TXT="%s"\nDone? ' "$RR" "$VAL"
read -r _
printf "echo 'Remove DNS record: %s TXT=\"%s\"'\n" "$RR" "$VAL" >>_cleanup
wait_dns "$DOMAIN" "$RR" "$VAL"
;;
dns.*)
[ -x "./dns-${2}.sh" ] || die "Hook $2: not executable" dns
log "Running hook: $2"
sh "./dns-${2}.sh" "$DOMAIN" "$RR" "$VAL" "$@" >>_cleanup || die "Hook $2 failed"
wait_dns "$DOMAIN" "$RR" "$VAL"
;;
http.*)
[ -z "$2" ] && die "Webroot required: domain set $NAME http /var/www/html"
printf %s "$TOK.$THUMB" >_challenge
deploy_file "_challenge" "$2/.well-known/acme-challenge/$TOK" cleanup
;;
esac
req "$(json url _auth '"type":"'"$1"'-01"')" "{}" >_res || die "Validation Trigger Fail"
}
# Usage: order [cert-name] [retry-file]
order() {
get_kid
FILE=$1
BACKUP=$2
# shellcheck disable=SC2046 # Intentionally split
set -- $(conf_get "cert $FILE")
[ -n "$1" ] || die "No names configured: $FILE" cert
log "Order $FILE: $*"
NAMES=$(IFS=,;for N in $1;do get_domain "$N" dns ip >/dev/null && printf '{"type":"%s","value":"%s"},' "$TYPE" "$N"; done)
[ -z "$BACKUP" ] && {
BACKUP="$FILE.order-$(date +%Y%m%d-%H%M%S)-$$"
ID=$(conf_has "cert $FILE ari_replace" && conf_get "cert $FILE ari") && ID=',"replaces":"'"$ID"'"'
conf_set "cert $FILE ari" '' '[^=]*'
req "$(json newOrder)" '{"identifiers":['"${NAMES%?}"']'"${2:+",\"profile\":\"$2\""}${ID}"'}' >_order || die 'Creating order failed' order
cp _order "$BACKUP"
}
ORDER_URL=$(sed -n 's/^location: *//pi' _order)
[ -n "$ORDER_URL" ] || die 'No order location'
for AUTH in $(json authorizations _order); do
req "$AUTH" '' >_auth || die 'Auth failed'
[ "$(json status _auth '"challenges"')" = 'pending' ] && challenge "$AUTH"
done
expand_key "cert $FILE key" "$FILE.key"
while req "$ORDER_URL" '' >_order; do
case "$(json status _order)" in
pending|processing)
SLEEP=$(seconds_to "$(sed -n 's/retry-after: *//pi' _order)") ||:
sleep "${SLEEP:-2}"
;;
ready)
log 'Sending CSR'
ALT=$(IFS=,;for N in $1;do get_domain "$N" DNS IP >/dev/null && printf '%s:%s,' "$TYPE" "$N"; done)
CSR=$(openssl req -new -sha256 -key "$FILE.key" -subj '/' -addext "subjectAltName=${ALT%,}" -outform DER | b64url)
req "$(json finalize _order)" '{"csr":"'"$CSR"'"}' >_res || die 'CSR failed'
;;
valid)
log "Downloading certificate: $FILE.crt"
req "$(json certificate _order)" '' >_res || die 'Certificate download failed'
# shellcheck disable=SC2046 # Intentionally split
set -- $(sed '/rel="alternate"/!d;s/.*<\|>.*//g' _res)
[ $# -gt 0 ] && ALT="1-$#" && {
log "Alternate chains available (${ALT%-1}):$(printf '\n - %s' "$@")"
ALT=$(conf_get "cert $FILE chain") && shift $((ALT-1)) 2>/dev/null && {
log "Downloading alternate certificate $ALT: $1"
req "$1" '' >_res || die 'Alternate certificate download failed'
}
}
sed '1,/^$/d' _res >"$FILE.crt"
conf_set "cert $FILE b64" "$(openssl x509 -in "$FILE.crt" -outform DER 2>/dev/null | b64url)"
EXP=$(openssl x509 -noout -enddate -in "$FILE.crt" | cut -d= -f2)
log "Expires: $EXP ($(($(seconds_to "$EXP")/86400)) days)"
conf_set "cert $FILE end" "$EXP"
conf_set "cert $FILE len" "$(seconds_to "$EXP")"
AKI=$(openssl x509 -noout -ext authorityKeyIdentifier -in "$FILE.crt" | sed -n '2s/[^0-9A-Fa-f]//gp' | hexB64) 2>/dev/null
[ -z "$AKI" ] || {
conf_set "cert $FILE ari_replace" 1
conf_set "cert $FILE ari" "$AKI.$(openssl x509 -noout -serial -in "$FILE.crt" | cut -d= -f2 | hexB64)"
}
# Deploy if configured
for EXT in key crt; do
TARGETS=$(conf_get "cert $FILE ${EXT}_path" | tr ',' ' ')
[ -n "$TARGETS" ] && deploy_file "$FILE.$EXT" "$TARGETS" && rm "$FILE.$EXT"
done
rm "$BACKUP"
cleanup
HOOK=$(conf_get "cert $FILE post_hook") && {
log 'Running post-hook'
sh -c "$HOOK" || log 'Warning: Post-hook failed'
}
return 0
;;
*) break ;;
esac
done
cleanup
die 'Order failed'
}
[ "$1" = lib ] && return 0
# Check dependencies
has cp curl cut date head od openssl sed sort tail tr || die "Missing command: $CMD"
# Touch config file if not writable
[ -w "$CERTX_CONF" ] || :>"$CERTX_CONF" || die "Cannot create config: $CERTX_CONF"
:>_cleanup
trap 'cleanup; rm -f _dir _auth _challenge _key _pub _order _cleanup _newkey _res' EXIT INT TERM
CA=$(conf_get _ca) || {
log 'No CA configured' ca
CA=$(ask 'CA directory URL') && conf_set _ca "$CA"
conf_ask _email 'Account email (optional)'
}
case "$1.$3" in
cert.order|cert.renew)
order "$2"
;;
cert.chain|cert.key_path|cert.crt_path|cert.post_hook)
K="$1 $2 $3"
shift 3
conf_set "$K" "$*"
;;
cert.revoke)
get_kid
URL=$(json revokeCert) || die 'No revokeCert URL'
B64=$(conf_get "cert $2 b64") || die "No cert $2 in base64 format"
[ -z "$4" ] || { [ "$4" -ge 0 ] && [ "$4" -le 10 ]; } 2>/dev/null || die 'Reason must be numeric 0-10'
log "Revoking certificate $2"
req "$URL" '{"certificate":"'"$B64"'","reason":'"${4:-0}"'}'>_res || die 'Revoke failed'
log 'Revoke DONE'
;;
domain.drop|cert.drop|ip.drop)
log "Deleting $1: $2"
conf_set "$1 $2" '' '[^=]*'
;;
ca-reset.)
log 'Deleting all CA configuration'
conf_set "_" '' '[^=]*'
;;
cert.?*|domain.dns|domain.http|domain.dns-persist|ip.http)
[ "$1" != cert ] || (IFS=,;for N in $3; do get_domain "$N"; done >/dev/null)
K="$1 $2"
shift 2
conf_set "$K" "$*"
[ "$1" != dns ] || [ "$2" = manual ] || [ -x "./dns-${2}.sh" ] || die 'No executable DNS validation hook!' dns
;;
cert.|domain.|ip.)
printf 'List of %ss:\n' "$1"
conf_find "$1" '' ' '
;;
account-rollover.)
conf_has _kid || die 'No account to rollover'
log 'Rolling over account key'
get_kid
expand_key _newkey _newkey
JWK=$(jwk _newkey)
URL=$(json keyChange) || die 'No keyChange URL'
req "$URL" "$(sign "$URL" '{"account":"'"$(conf_get _kid)"'","oldKey":'"$(conf_get _jwk)"'}' '"jwk":'"$JWK" _newkey)">_res || die 'Key rollover failed'
conf_set _key "$(conf_get _newkey)"
conf_set _jwk "$JWK"
conf_set _thumb "$(shaB64 "$JWK")"
conf_set _newkey ''
log 'Account key rollover completed'
;;
account-deactivate.)
conf_has _kid || die 'No account to deactivate'
log 'Deactivating account'
get_kid
req "$(conf_get _kid)" '{"status":"deactivated"}'>_res || die 'Account deactivation failed'
conf_set '_' '' '\(kid\|key\|jwk\|thumb\)'
log 'Account deactivated successfully'
;;
authz-deactivate.)
[ -z "$2" ] && die 'Authorization URL required'
log "Deactivating authorization: $2"
get_kid
req "$2" '{"status":"deactivated"}' >_res || die 'Authorization deactivation failed'
log "Authorization status: $(json status _res)"
;;
renew-all.)
RENEW=$(IFS=$NL;R=${2:-20%};for C in $(conf_find cert end);do
END=${C##*= } NAME=${C%% =*}
case $R in *%) LEN=$(conf_get "cert $NAME len") && DUE=$((LEN*${R%\%}/100)) || DUE=86400;; *) DUE=$((R*86400));; esac
[ -z "$2" ] && ID=$(conf_get "cert $NAME ari") && get_kid && [ -n "$ARI" ] && DUE=0 && {
# Stored ARI start reached - renew
END=$(seconds_to "$(conf_get "cert $NAME ari_start")") && [ "$END" -le 0 ] || {
RA=$(conf_get "cert $NAME ari_retry") && [ "$RA" -gt "$NOW" ] || {
req "$ARI/$ID" '' >_res && START=$(json start _res) && conf_set "cert $NAME ari_start" "$START" && END=$START
RA=$(seconds_to "$(sed -n 's/retry-after: *//pi' _res)") && [ "${RA:-0}" -gt 0 ] && conf_set "cert $NAME ari_retry" "$((RA+NOW))"
}
}
}
[ "$(seconds_to "${END:-$DUE}")" -lt "$DUE" ] && printf ' %s' "$NAME" ||:
done 2>/dev/null)
[ -z "$RENEW" ] && { log 'Nothing to renew'; exit 0; }
log "Renewing: $RENEW"
for CERT in $RENEW; do
( order "$CERT" ) || log "Warning: Failed to renew $CERT"
done
;;
retry.)
[ -f "$2" ] && CERT=${2%.*} && conf_has "cert $CERT" || die "Invalid order: $2"
cp "$2" _order && order "$CERT" "$2"
;;
*)
[ $# -gt 0 ] && [ "$1" != help ] && die 'Invalid command!' "-*"
usage "${2}"
;;
esac
#
#