2026-04-05 00:58:28 -04:00
#!/usr/bin/env sh
2026-03-31 18:10:04 -04:00
# shellcheck disable=SC2034,SC2154
#
# acme.sh deploy hook: BytePlus Application Load Balancer (ALB)
# https://github.com/acmesh-official/acme.sh/wiki/deployhooks
#
# Deploys SSL/TLS certificates issued by acme.sh to BytePlus ALB.
2026-04-09 02:43:37 -04:00
# Supports automatic renewal with zero-downtime certificate rotation
# for certificates that have already been uploaded and have a saved
# BytePlus CertificateId.
2026-03-31 18:10:04 -04:00
#
# ┌─────────────────────────────────────────────────────────────────────┐
# │ FIRST TIME (new domain) │
# │ 1. acme.sh --issue -d example.com -w /var/www/html/ │
2026-04-09 02:43:37 -04:00
# │ 2. Upload/import the certificate to BytePlus ALB manually │
# │ 3. Save/configure the existing CertificateId for this hook │
# │ 4. Manually assign cert to ALB Listener (one-time only) │
2026-03-31 18:10:04 -04:00
# │ │
2026-04-09 02:43:37 -04:00
# │ RENEWAL (fully automatic after CertificateId is configured) │
2026-03-31 18:10:04 -04:00
# │ acme.sh cron triggers renew → deploy hook runs automatically │
# │ → ReplaceCertificate (UpdateMode=new) — single API call │
# │ → All attached listeners updated, old cert auto-deleted │
# └─────────────────────────────────────────────────────────────────────┘
#
# Required environment variables:
# export BYTEPLUS_ACCESS_KEY="AKAPxxxxxxxxxx"
# export BYTEPLUS_SECRET_KEY="your-secret-key"
#
# Optional environment variables:
# export BYTEPLUS_REGION="ap-southeast-3" # default: ap-southeast-3
# export BYTEPLUS_HOST="alb.ap-southeast-3.byteplusapi.com" # custom API host
# export BYTEPLUS_PROJECT_NAME="live" # default: "default" project
# export BYTEPLUS_CERT_NAME="" # default: acme-{domain}-{YYYYMMDD-HHMM}
# export BYTEPLUS_CERT_DESCRIPTION="" # default: empty
# export BYTEPLUS_DELETE_OLD_CERT="true" # default: true — auto-delete after replace
#
# API notes:
# - All BytePlus ALB APIs use GET with query string parameters
# - Request signing: HMAC-SHA256 with signed headers host;x-date
# - PublicKey/PrivateKey are URL-encoded (RFC 3986) in query string
# - ReplaceCertificate with UpdateMode=new uploads + replaces in 1 call
#
# Dependencies: curl, openssl, awk (standard on most Linux)
#
# Docs:
# Signing — https://docs.byteplus.com/en/docs/byteplus-platform/reference-how-to-calculate-a-signature
# ALB API — https://docs.byteplus.com/en/docs/byteplus-alb
# ══════════════════════════════════════════════════════════════════════════════
# Constants
# ══════════════════════════════════════════════════════════════════════════════
# SHA-256 hash of empty string (used for GET requests with no body)
_BYTEPLUS_EMPTY_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
# ══════════════════════════════════════════════════════════════════════════════
# Main deploy function — called by acme.sh
# ══════════════════════════════════════════════════════════════════════════════
byteplus_alb_deploy( ) {
_cdomain = " $1 "
_ckey = " $2 "
_ccert = " $3 "
_cca = " $4 "
_cfullchain = " $5 "
_debug _cdomain " $_cdomain "
_debug _ckey " $_ckey "
_debug _ccert " $_ccert "
_debug _cca " $_cca "
_debug _cfullchain " $_cfullchain "
# ── 1. Load & validate credentials ──────────────────────────────────────────
# Preserve environment values before _getdeployconf (which may reset them)
_env_project_name = " ${ BYTEPLUS_PROJECT_NAME :- } "
_env_delete_old = " ${ BYTEPLUS_DELETE_OLD_CERT :- } "
_getdeployconf BYTEPLUS_ACCESS_KEY
_getdeployconf BYTEPLUS_SECRET_KEY
_getdeployconf BYTEPLUS_REGION
_getdeployconf BYTEPLUS_HOST
_getdeployconf BYTEPLUS_PROJECT_NAME
_getdeployconf BYTEPLUS_DELETE_OLD_CERT
_getdeployconf BYTEPLUS_CERT_NAME
# Restore from environment if _getdeployconf cleared them
if [ -z " $BYTEPLUS_PROJECT_NAME " ] && [ -n " $_env_project_name " ] ; then
_debug "Restoring BYTEPLUS_PROJECT_NAME from environment"
BYTEPLUS_PROJECT_NAME = " $_env_project_name "
fi
if [ -z " $BYTEPLUS_DELETE_OLD_CERT " ] && [ -n " $_env_delete_old " ] ; then
BYTEPLUS_DELETE_OLD_CERT = " $_env_delete_old "
fi
# Validate required credentials
if [ -z " $BYTEPLUS_ACCESS_KEY " ] ; then
_err "BYTEPLUS_ACCESS_KEY is not set."
_err "Please run: export BYTEPLUS_ACCESS_KEY=\"your-access-key\""
return 1
fi
if [ -z " $BYTEPLUS_SECRET_KEY " ] ; then
_err "BYTEPLUS_SECRET_KEY is not set."
_err "Please run: export BYTEPLUS_SECRET_KEY=\"your-secret-key\""
return 1
fi
# Save credentials for future runs
_savedeployconf BYTEPLUS_ACCESS_KEY " $BYTEPLUS_ACCESS_KEY "
_savedeployconf BYTEPLUS_SECRET_KEY " $BYTEPLUS_SECRET_KEY "
# Region (default: ap-southeast-3)
BYTEPLUS_REGION = " ${ BYTEPLUS_REGION :- ap -southeast-3 } "
_savedeployconf BYTEPLUS_REGION " $BYTEPLUS_REGION "
# Project name
if [ -n " $BYTEPLUS_PROJECT_NAME " ] ; then
_savedeployconf BYTEPLUS_PROJECT_NAME " $BYTEPLUS_PROJECT_NAME "
_info " Using project: $BYTEPLUS_PROJECT_NAME "
else
_info "WARNING: BYTEPLUS_PROJECT_NAME is not set. Cert will go to 'default' project."
fi
# Delete old cert toggle (default: true)
BYTEPLUS_DELETE_OLD_CERT = " ${ BYTEPLUS_DELETE_OLD_CERT :- true } "
_savedeployconf BYTEPLUS_DELETE_OLD_CERT " $BYTEPLUS_DELETE_OLD_CERT "
# API host — custom override or auto-build from region
if [ -n " $BYTEPLUS_HOST " ] ; then
_BYTEPLUS_HOST = " $BYTEPLUS_HOST "
_savedeployconf BYTEPLUS_HOST " $BYTEPLUS_HOST "
else
_BYTEPLUS_HOST = " alb. ${ BYTEPLUS_REGION } .byteplusapi.com "
fi
_info " Using API host: $_BYTEPLUS_HOST "
_BYTEPLUS_SERVICE = "alb"
# ── 2. Build certificate name ────────────────────────────────────────────────
_date_tag = $( date -u +%Y%m%d-%H%M)
# Replace wildcard * and dots for a valid cert name
_safe_domain = $( echo " $_cdomain " | sed 's/\*\.//g' | sed 's/\./-/g' )
2026-04-05 00:55:12 -04:00
# Safe identifier version for deployconf keys: map all non [A-Za-z0-9_] to _
_conf_key = $( echo " $_cdomain " | sed 's/^\*\.//' | sed 's/[^A-Za-z0-9_]/_/g' )
2026-03-31 18:10:04 -04:00
if [ -z " $BYTEPLUS_CERT_NAME " ] ; then
BYTEPLUS_CERT_NAME = " acme- ${ _safe_domain } - ${ _date_tag } "
fi
# Enforce BytePlus naming rules: start with letter, max 128 chars
2026-04-05 01:01:28 -04:00
BYTEPLUS_CERT_NAME = $( echo " $BYTEPLUS_CERT_NAME " | sed 's/[^A-Za-z0-9._-]/-/g' )
case " $BYTEPLUS_CERT_NAME " in
2026-04-24 00:21:43 -04:00
[ A-Za-z] *) ; ;
*)
BYTEPLUS_CERT_NAME = " a $BYTEPLUS_CERT_NAME "
; ;
2026-04-05 01:01:28 -04:00
esac
BYTEPLUS_CERT_NAME = $( echo " $BYTEPLUS_CERT_NAME " | cut -c1-128)
2026-03-31 18:10:04 -04:00
_info " Certificate name: $BYTEPLUS_CERT_NAME "
# ── 3. Read cert and key ─────────────────────────────────────────────────────
# BytePlus requires NO blank lines between PEM blocks in the certificate chain
_public_key = $( sed '/^[[:space:]]*$/d' " $_cfullchain " | tr -d '\r' )
_private_key = $( sed '/^[[:space:]]*$/d' " $_ckey " | tr -d '\r' )
if [ -z " $_public_key " ] || [ -z " $_private_key " ] ; then
_err "Failed to read certificate or key file."
return 1
fi
# ── 4. Deploy: first-time upload or renewal replace ─────────────────────────
_getdeployconf " BYTEPLUS_CERT_ID_ ${ _conf_key } "
_old_cert_id = $( eval echo " \$BYTEPLUS_CERT_ID_ ${ _conf_key } " )
if [ -z " $_old_cert_id " ] ; then
_byteplus_first_time_deploy
else
_byteplus_renewal_deploy
fi
# Check if deploy step set _new_cert_id
if [ -z " $_new_cert_id " ] ; then
return 1
fi
# ── 5. Save new CertificateId for next renewal ───────────────────────────────
_savedeployconf " BYTEPLUS_CERT_ID_ ${ _conf_key } " " $_new_cert_id "
_info " Saved CertificateId ' $_new_cert_id ' for domain ' $_cdomain '. "
return 0
}
# ══════════════════════════════════════════════════════════════════════════════
# Deploy: First time — UploadCertificate
# ══════════════════════════════════════════════════════════════════════════════
_byteplus_first_time_deploy( ) {
2026-04-05 00:57:10 -04:00
_info "No previous CertificateId found."
_err "Refusing to upload certificate material because this hook passes PublicKey/PrivateKey as request parameters."
_err "Uploading a private key in the request URL can leak it via logs, proxies, and process listings."
2026-04-09 02:43:52 -04:00
_err " Please upload the certificate to BytePlus manually for the initial deployment, set BYTEPLUS_CERT_ID_ ${ _conf_key } to that CertificateId, and rerun. "
_err "This hook stores CertificateId values per domain using deployconf, so the variable name must include the current domain-specific suffix."
2026-04-05 00:57:10 -04:00
_err "This hook must be updated to send PublicKey and PrivateKey in a POST body before automatic first-time upload can be enabled safely."
return 1
2026-03-31 18:10:04 -04:00
}
# ══════════════════════════════════════════════════════════════════════════════
# Deploy: Renewal — ReplaceCertificate (UpdateMode=new)
# ══════════════════════════════════════════════════════════════════════════════
_byteplus_renewal_deploy( ) {
_info " Replacing old certificate ' $_old_cert_id ' (UpdateMode=new)... "
2026-04-09 02:42:55 -04:00
_err "Refusing to replace certificate material because this hook passes PublicKey/PrivateKey as request parameters."
_err "Uploading a private key in the request URL can leak it via logs, proxies, and process listings."
_err "Please replace the certificate in BytePlus manually for renewal until this hook is updated to send PublicKey and PrivateKey in a POST body safely."
return 1
2026-03-31 18:10:04 -04:00
}
# ══════════════════════════════════════════════════════════════════════════════
# Delete old certificate (with retry)
# ══════════════════════════════════════════════════════════════════════════════
_byteplus_delete_old_cert( ) {
_del_cert_id = " $1 "
_info "Waiting 5s for cert status to settle..."
2026-04-05 01:00:39 -04:00
_sleep 5
2026-03-31 18:10:04 -04:00
_info " Deleting old certificate ' $_del_cert_id '... "
_del_response = $( _byteplus_alb_api "DeleteCertificate" " CertificateId= ${ _del_cert_id } " )
if echo " $_del_response " | grep -q '"Error"' ; then
_info "Delete failed, retrying in 10s..."
2026-04-05 01:00:39 -04:00
_sleep 10
2026-03-31 18:10:04 -04:00
_del_response = $( _byteplus_alb_api "DeleteCertificate" " CertificateId= ${ _del_cert_id } " )
if echo " $_del_response " | grep -q '"Error"' ; then
_info " Warning: Could not delete old certificate ' $_del_cert_id '. "
_info " Error: $( _byteplus_extract_error " $_del_response " ) "
_info "Please remove it manually from BytePlus Console."
else
_info " Old certificate ' $_del_cert_id ' deleted (retry succeeded). "
fi
else
_info " Old certificate ' $_del_cert_id ' deleted. "
fi
}
# ══════════════════════════════════════════════════════════════════════════════
# JSON response helpers
# ══════════════════════════════════════════════════════════════════════════════
# Extract CertificateId from API response JSON
_byteplus_extract_cert_id( ) {
echo " $1 " | _egrep_o '"CertificateId"\s*:\s*"[^"]*"' | head -1 | _egrep_o '"[^"]*"$' | tr -d '"'
}
# Extract error message from API response JSON
_byteplus_extract_error( ) {
_code = $( echo " $1 " | _egrep_o '"Code"\s*:\s*"[^"]*"' | head -1 | _egrep_o '"[^"]*"$' | tr -d '"' )
_msg = $( echo " $1 " | _egrep_o '"Message"\s*:\s*"[^"]*"' | head -1 | _egrep_o '"[^"]*"$' | tr -d '"' )
if [ -n " $_code " ] ; then
printf '%s — %s' " $_code " " $_msg "
else
printf '%s' " $1 "
fi
}
# ══════════════════════════════════════════════════════════════════════════════
# BytePlus ALB API caller
# ══════════════════════════════════════════════════════════════════════════════
# Usage: _byteplus_alb_api ACTION [param1=val1] [param2=val2] ...
# All parameters sent via GET query string. Signing: HMAC-SHA256, host;x-date.
_byteplus_alb_api( ) {
_action = " $1 "
shift
# Build query string — all params go in URL
_query_params = " Action= ${ _action } &Version=2020-04-01 "
for _param in " $@ " ; do
_pname = " ${ _param %%=* } "
_pval = " ${ _param #*= } "
_query_params = " ${ _query_params } & ${ _pname } = $( _byteplus_urlencode " $_pval " ) "
done
# Timestamps
_x_date = $( date -u +%Y%m%dT%H%M%SZ)
_date_only = $( date -u +%Y%m%d)
# Sort query params for canonical request
2026-04-05 00:58:52 -04:00
_sorted_query = $( echo " $_query_params " | tr '&' '\n' | LC_ALL = C sort | tr '\n' '&' | sed 's/&$//' )
2026-03-31 18:10:04 -04:00
# Canonical headers — only host and x-date
_canonical_headers = " host: ${ _BYTEPLUS_HOST }
x-date:${ _x_date }
"
_signed_headers = "host;x-date"
# Canonical request
_canonical_request = " GET
/
${ _sorted_query }
${ _canonical_headers }
${ _signed_headers }
${ _BYTEPLUS_EMPTY_HASH } "
2026-04-05 00:59:17 -04:00
# Do not log _canonical_request because the query string may contain
# URL-encoded certificate or private key material.
2026-03-31 18:10:04 -04:00
# Hash of canonical request
2026-04-05 01:11:31 -04:00
# _digest is provided by acme.sh and works across OpenSSL versions.
_cr_hash = $( printf '%s' " $_canonical_request " | _digest sha256 hex)
2026-03-31 18:10:04 -04:00
# Credential scope
_credential_scope = " ${ _date_only } / ${ BYTEPLUS_REGION } / ${ _BYTEPLUS_SERVICE } /request "
# String to sign
_string_to_sign = " HMAC-SHA256
${ _x_date }
${ _credential_scope }
${ _cr_hash } "
_debug2 _string_to_sign " $_string_to_sign "
# Signing key derivation (HMAC chain)
2026-04-05 01:11:31 -04:00
# _hmac <algo> <hex-key> reads data from stdin and returns a hex digest.
# acme.sh's _hmac abstracts away OpenSSL version differences, so this works
# on both modern (-mac HMAC -macopt hexkey:) and older (-hmac) OpenSSL builds.
#
# The first step seeds the chain from the raw secret key, so we convert it
# to hex first with _hex_dump (also an acme.sh built-in).
_secret_hex = $( printf '%s' " $BYTEPLUS_SECRET_KEY " | _hex_dump | tr -d ' \n' )
2026-04-24 00:21:43 -04:00
_k_date = $( printf '%s' " $_date_only " | _hmac sha256 " $_secret_hex " hex)
_k_region = $( printf '%s' " $BYTEPLUS_REGION " | _hmac sha256 " $_k_date " hex)
_k_service = $( printf '%s' " $_BYTEPLUS_SERVICE " | _hmac sha256 " $_k_region " hex)
_k_signing = $( printf '%s' "request" | _hmac sha256 " $_k_service " hex)
2026-03-31 18:10:04 -04:00
# Final signature
2026-04-05 01:11:31 -04:00
_signature = $( printf '%s' " $_string_to_sign " | _hmac sha256 " $_k_signing " hex)
2026-03-31 18:10:04 -04:00
# Authorization header
_auth = " HMAC-SHA256 Credential= ${ BYTEPLUS_ACCESS_KEY } / ${ _credential_scope } , SignedHeaders= ${ _signed_headers } , Signature= ${ _signature } "
2026-04-05 01:01:06 -04:00
_secure_debug2 _auth " $_auth "
2026-03-31 18:10:04 -04:00
2026-04-09 02:43:11 -04:00
# Send request parameters in the POST body instead of the URL query string.
# This avoids exposing sensitive or large values in debug-logged URLs and
# reduces the risk of exceeding URL length limits.
_url = " https:// ${ _BYTEPLUS_HOST } / "
_body = " $_sorted_query "
2026-03-31 18:10:04 -04:00
2026-04-05 00:57:37 -04:00
_saved_H1 = " ${ _H1 :- } "
_saved_H2 = " ${ _H2 :- } "
_saved_H3 = " ${ _H3 :- } "
2026-04-09 02:43:11 -04:00
_saved_H4 = " ${ _H4 :- } "
2026-04-09 02:56:51 -04:00
_saved_H5 = " ${ _H5 :- } "
2026-04-05 00:57:37 -04:00
_H1 = " Authorization: ${ _auth } "
_H2 = " X-Date: ${ _x_date } "
_H3 = " Host: ${ _BYTEPLUS_HOST } "
2026-04-09 02:43:11 -04:00
_H4 = "Content-Type: application/x-www-form-urlencoded"
2026-04-09 02:56:51 -04:00
_H5 = ""
2026-04-24 00:21:43 -04:00
2026-04-09 02:43:11 -04:00
_response = " $( _post " $_body " " $_url " "" "POST" ) "
2026-04-05 00:57:37 -04:00
_request_ret = " $? "
_H1 = " $_saved_H1 "
_H2 = " $_saved_H2 "
_H3 = " $_saved_H3 "
2026-04-09 02:43:11 -04:00
_H4 = " $_saved_H4 "
2026-04-09 02:56:51 -04:00
_H5 = " $_saved_H5 "
2026-04-05 00:57:37 -04:00
if [ " $_request_ret " != "0" ] ; then
_err " byteplus_alb_api request failed for [ $_action ] "
return 1
fi
2026-03-31 18:10:04 -04:00
_debug2 " _byteplus_alb_api response [ $_action ] " " $_response "
printf '%s' " $_response "
}
# ══════════════════════════════════════════════════════════════════════════════
2026-04-05 00:58:15 -04:00
# URL encode (RFC 3986)
2026-03-31 18:10:04 -04:00
# ══════════════════════════════════════════════════════════════════════════════
_byteplus_urlencode( ) {
2026-04-09 02:43:25 -04:00
printf '%s' " $1 " | _url_encode
2026-03-31 18:10:04 -04:00
}