From 6efd6d5b5a7c582f35807d758a264252bb69e2cf Mon Sep 17 00:00:00 2001 From: ACHMAD ALIF NASRULLOH <106044706+achmadalifn4@users.noreply.github.com> Date: Wed, 1 Apr 2026 05:10:04 +0700 Subject: [PATCH] Add BytePlus ALB deployment script This script deploys SSL/TLS certificates issued by acme.sh to BytePlus Application Load Balancer (ALB), supporting automatic renewal with zero-downtime certificate rotation. --- deploy/byteplus_alb.sh | 449 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 deploy/byteplus_alb.sh diff --git a/deploy/byteplus_alb.sh b/deploy/byteplus_alb.sh new file mode 100644 index 00000000..0cffa750 --- /dev/null +++ b/deploy/byteplus_alb.sh @@ -0,0 +1,449 @@ +#!/usr/bin/env bash +# 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. +# Supports automatic renewal with zero-downtime certificate rotation. +# +# ┌─────────────────────────────────────────────────────────────────────┐ +# │ FIRST TIME (new domain) │ +# │ 1. acme.sh --issue -d example.com -w /var/www/html/ │ +# │ 2. acme.sh --deploy -d example.com --deploy-hook byteplus_alb │ +# │ → UploadCertificate → saves CertificateId │ +# │ 3. Manually assign cert to ALB Listener (one-time only) │ +# │ │ +# │ RENEWAL (fully automatic) │ +# │ 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 + _getdeployconf BYTEPLUS_CERT_DESCRIPTION + + # 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') + # Underscore version for bash variable names (hyphens not allowed in var names) + _conf_key=$(echo "$_cdomain" | sed 's/\*\.//g' | sed 's/\./_/g') + + 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 + BYTEPLUS_CERT_NAME=$(echo "$BYTEPLUS_CERT_NAME" | sed 's/[^a-zA-Z0-9._-]/-/g' | cut -c1-128) + + _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() { + _info "No previous CertificateId found. Uploading new certificate..." + + if [ -n "$BYTEPLUS_PROJECT_NAME" ]; then + _upload_response=$(_byteplus_alb_api "UploadCertificate" \ + "CertificateType=Server" \ + "CertificateName=${BYTEPLUS_CERT_NAME}" \ + "ProjectName=${BYTEPLUS_PROJECT_NAME}" \ + "PublicKey=${_public_key}" \ + "PrivateKey=${_private_key}") + else + _upload_response=$(_byteplus_alb_api "UploadCertificate" \ + "CertificateType=Server" \ + "CertificateName=${BYTEPLUS_CERT_NAME}" \ + "PublicKey=${_public_key}" \ + "PrivateKey=${_private_key}") + fi + + _debug2 _upload_response "$_upload_response" + + _new_cert_id=$(_byteplus_extract_cert_id "$_upload_response") + + if [ -z "$_new_cert_id" ]; then + _err "UploadCertificate failed: $(_byteplus_extract_error "$_upload_response")" + _debug2 "Full response" "$_upload_response" + return 1 + fi + + _info "Certificate uploaded. CertificateId: $_new_cert_id" + + # Set description if provided + if [ -n "$BYTEPLUS_CERT_DESCRIPTION" ]; then + _info "Setting certificate description..." + _byteplus_alb_api "ModifyCertificateAttributes" \ + "CertificateId=${_new_cert_id}" \ + "CertificateName=${BYTEPLUS_CERT_NAME}" \ + "Description=${BYTEPLUS_CERT_DESCRIPTION}" >/dev/null + fi + + _info "" + _info "╔══════════════════════════════════════════════════════════════════╗" + _info "║ ACTION REQUIRED (one-time only) ║" + _info "║ Assign CertificateId '$_new_cert_id'" + _info "║ to your ALB Listener in BytePlus Console. ║" + _info "║ After that, all future renewals will be fully automatic. ║" + _info "╚══════════════════════════════════════════════════════════════════╝" + _info "" +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Deploy: Renewal — ReplaceCertificate (UpdateMode=new) +# ══════════════════════════════════════════════════════════════════════════════ + +_byteplus_renewal_deploy() { + _info "Replacing old certificate '$_old_cert_id' (UpdateMode=new)..." + + if [ -n "$BYTEPLUS_PROJECT_NAME" ]; then + _replace_response=$(_byteplus_alb_api "ReplaceCertificate" \ + "OldCertificateId=${_old_cert_id}" \ + "UpdateMode=new" \ + "CertificateName=${BYTEPLUS_CERT_NAME}" \ + "ProjectName=${BYTEPLUS_PROJECT_NAME}" \ + "PublicKey=${_public_key}" \ + "PrivateKey=${_private_key}") + else + _replace_response=$(_byteplus_alb_api "ReplaceCertificate" \ + "OldCertificateId=${_old_cert_id}" \ + "UpdateMode=new" \ + "CertificateName=${BYTEPLUS_CERT_NAME}" \ + "PublicKey=${_public_key}" \ + "PrivateKey=${_private_key}") + fi + + _debug2 _replace_response "$_replace_response" + + _new_cert_id=$(_byteplus_extract_cert_id "$_replace_response") + + if [ -z "$_new_cert_id" ]; then + _err "ReplaceCertificate failed: $(_byteplus_extract_error "$_replace_response")" + _debug2 "Full response" "$_replace_response" + return 1 + fi + + _info "Certificate replaced successfully on all attached listeners." + _info "New CertificateId: $_new_cert_id" + + # Auto-cleanup old certificate + if [ "$BYTEPLUS_DELETE_OLD_CERT" = "true" ]; then + _byteplus_delete_old_cert "$_old_cert_id" + else + _info "Auto-delete disabled. Old certificate '$_old_cert_id' kept in inventory." + fi +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Delete old certificate (with retry) +# ══════════════════════════════════════════════════════════════════════════════ + +_byteplus_delete_old_cert() { + _del_cert_id="$1" + + _info "Waiting 5s for cert status to settle..." + sleep 5 + + _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..." + sleep 10 + _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 + _sorted_query=$(echo "$_query_params" | tr '&' '\n' | sort | tr '\n' '&' | sed 's/&$//') + + # 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}" + + _debug2 _canonical_request "$_canonical_request" + + # Hash of canonical request + _cr_hash=$(printf '%s' "$_canonical_request" | openssl dgst -sha256 | awk '{print $NF}') + + # 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) + _k_date=$(printf '%s' "$_date_only" | openssl dgst -sha256 -hmac "$BYTEPLUS_SECRET_KEY" | awk '{print $NF}') + _k_region=$(printf '%s' "$BYTEPLUS_REGION" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:${_k_date}" | awk '{print $NF}') + _k_service=$(printf '%s' "$_BYTEPLUS_SERVICE" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:${_k_region}" | awk '{print $NF}') + _k_signing=$(printf '%s' "request" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:${_k_service}" | awk '{print $NF}') + + # Final signature + _signature=$(printf '%s' "$_string_to_sign" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:${_k_signing}" | awk '{print $NF}') + + # Authorization header + _auth="HMAC-SHA256 Credential=${BYTEPLUS_ACCESS_KEY}/${_credential_scope}, SignedHeaders=${_signed_headers}, Signature=${_signature}" + + _debug2 _auth "$_auth" + + # Build URL and execute GET request + _url="https://${_BYTEPLUS_HOST}/?${_sorted_query}" + + _response=$(curl -s --connect-timeout 10 --max-time 60 -X GET \ + -H "Authorization: ${_auth}" \ + -H "X-Date: ${_x_date}" \ + -H "Host: ${_BYTEPLUS_HOST}" \ + "${_url}") + + _debug2 "_byteplus_alb_api response [$_action]" "$_response" + printf '%s' "$_response" +} + +# ══════════════════════════════════════════════════════════════════════════════ +# URL encode (RFC 3986) — awk-based for performance +# ══════════════════════════════════════════════════════════════════════════════ + +_byteplus_urlencode() { + printf '%s' "$1" | awk 'BEGIN { + for (i = 0; i <= 255; i++) { + c = sprintf("%c", i) + if (c ~ /[a-zA-Z0-9.~_\-]/) + safe[i] = c + else + safe[i] = sprintf("%%%02X", i) + } + } + { + n = length($0) + for (i = 1; i <= n; i++) { + c = substr($0, i, 1) + printf "%s", safe[ord(c)] + } + # Print newline as %0A (except trailing, which command substitution strips) + if (NR > 0) printf "%%0A" + } + function ord(c, i2) { + for (i2 = 0; i2 <= 255; i2++) + if (sprintf("%c", i2) == c) return i2 + return 0 + } + END { }' | sed 's/%0A$//' +}