阿里云 ESA 配合 ACME.sh 自动上传自定义证书

众所周知,Cloudflare 是出了名的 CDN 耐打王,但是耐打王在国内访问大部分情况下差不多就是减速器,特别是晚高峰跨境出口堵的时候,更加要命了。而国内有两个希望模仿 Cloudflare 的 CDN —— 阿里云的 ESA 和腾讯云的 EdgeOne。当然,至于耐不耐打,我想对于 EdgeOne 已经有答案了:3.95Gbps 的 DDoS 即可轻松打死,有图有真相,打死了是永久封域名。详情可见:使用Cloudflare自定义主机名实现国内外流量分流
对于阿里云的 ESA,我还真没用过,不过考虑到国内互联网公司的尿性,我感觉应该和 EdgeOne 也大差不差,估计都是卧龙凤雏。但是至少页面写清楚了说能抗不大于 10Gbps,至少明码标价了。
我借到了备案的域名,于是我计划尝试一下让 ESA 负责国内静态资源的分发,并且禁止境外访问,而国外则继续用耐打王 Cloudflare,这样就可以充分利用这两个 CDN 的长处。但是,这会遇到一个问题:常见的证书验证 CA 都是在国外进行域名所有权验证,这对于域名通过 SaaS 托管在 Cloudflare 没问题,它可以自动处理,但是 ESA 要进行自动证书签发就麻烦了——对于 HTTP 域名所有权挑战,访问的是 Cloudflare 的节点,而不是 ESA 的,导致证书签发不下来。
而解决这个问题只有一个办法:手动上传证书,但是,手动上传...万一忘了,证书过期了,岂不是炸缸了?!
于是,我就想能看看阿里云有没有类似 Cloudflare 那样的 API,可以通过特定的 HTTP 请求进行设置和管理,一查,唉还真有!开干!

获取阿里云 AK 和 SK

首先,需要拿到阿里云的 Access Key 和 Secret Access Key 用于编程访问,建议是创建 RAM 用户,并且严格设置权限最小化,只授予 ESA 的配置权限。
至于怎么创建,我建议你去问阿里云的 AI 助手吧,用户名可以随便创建(比如neko-cert)。创建完成 RAM 用户后,确定最后只授予 AliyunESAFullAccess 这一个权限。
你最终会下载到这样一个 CSV 文件:

UserPrincipalName,Password,AccessKeyId,AccessKeySecret,SecurityPhone,SecurityEmail
neko-cert@1145141919810.onaliyun.com,,WWWNEKOPARAUK9981S5WFQGGDS,ScsGTX690SLI6FS81F4fswgbj,,

其中,WWWNEKOPARAUK9981S5WFQGGDS 是 AK,ScsGTX690SLI6FS81F4fswgbj 是 SK。

编写证书上传程序

让 ChatGPT 查阅了阿里云的文档,他给我写了一个推送的.sh脚本,命名为 esa-cert-sync.sh:

#!/bin/sh
set -eu
umask 077

BASE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
CONFIG=${ESA_CONFIG:-"$BASE_DIR/esa-cert-sync.conf"}
[ -r "$CONFIG" ] || { echo "Cannot read config: $CONFIG" >&2; exit 1; }
# Config is a trusted, root-owned shell file.
. "$CONFIG"

: "${ALIYUN_ACCESS_KEY_ID:?missing ALIYUN_ACCESS_KEY_ID}"
: "${ALIYUN_ACCESS_KEY_SECRET:?missing ALIYUN_ACCESS_KEY_SECRET}"
: "${ESA_SITE_ID:?missing ESA_SITE_ID}"
: "${ESA_CERT_NAME:?missing ESA_CERT_NAME}"
: "${ESA_CERT_FILE:?missing ESA_CERT_FILE}"
: "${ESA_KEY_FILE:?missing ESA_KEY_FILE}"

REGION=${ESA_REGION:-cn-hangzhou}
case "$REGION" in
  cn-hangzhou|ap-southeast-1) ;;
  *) echo "Unsupported ESA_REGION: $REGION" >&2; exit 1 ;;
esac
HOST="esa.$REGION.aliyuncs.com"

for bin in curl openssl od tr sed grep mktemp date; do
  command -v "$bin" >/dev/null 2>&1 || { echo "Missing command: $bin" >&2; exit 1; }
done
[ -s "$ESA_CERT_FILE" ] && [ -s "$ESA_KEY_FILE" ] || {
  echo 'Missing or empty certificate/private key file' >&2; exit 1;
}
openssl x509 -in "$ESA_CERT_FILE" -noout >/dev/null
openssl pkey -in "$ESA_KEY_FILE" -noout >/dev/null

# Verify that the leaf certificate and private key belong together.
cert_pub=$(openssl x509 -in "$ESA_CERT_FILE" -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256)
key_pub=$(openssl pkey -in "$ESA_KEY_FILE" -pubout -outform DER | openssl dgst -sha256)
[ "$cert_pub" = "$key_pub" ] || { echo 'Certificate and private key do not match' >&2; exit 1; }

# The first upload creates a certificate. Subsequent runs update the saved ID.
# ESA_CERT_ID in config overrides the locally saved ID if set.
mkdir -p "$BASE_DIR/state"
STATE_ID_FILE="$BASE_DIR/state/cert-id"
ESA_CERT_ID_CONFIGURED=${ESA_CERT_ID:-}
if [ -z "${ESA_CERT_ID:-}" ] && [ -s "$STATE_ID_FILE" ]; then
  ESA_CERT_ID=$(cat "$STATE_ID_FILE")
fi
case "${ESA_CERT_ID:-}" in
  *[!a-zA-Z0-9_-]* ) echo 'Invalid certificate ID' >&2; exit 1 ;;
esac
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT HUP INT TERM

# Percent-encode arbitrary UTF-8/PEM bytes, suitable for x-www-form-urlencoded.
encode_file() {
  od -An -v -tx1 "$1" | tr -d ' \n' | sed 's/../%&/g' | tr 'a-f' 'A-F'
}
encode_text() {
  printf %s "$1" | od -An -v -tx1 | tr -d ' \n' | sed 's/../%&/g' | tr 'a-f' 'A-F'
}
sha256_file() {
  openssl dgst -sha256 "$1" | sed 's/^.*= //'
}

BODY="$TMP/request.body"
{
  printf 'SiteId='; encode_text "$ESA_SITE_ID"
  printf '&Type=upload&Name='; encode_text "$ESA_CERT_NAME"
  if [ -n "${ESA_CERT_ID:-}" ]; then
    printf '&Id='; encode_text "$ESA_CERT_ID"
  fi
  printf '&Certificate='; encode_file "$ESA_CERT_FILE"
  printf '&PrivateKey='; encode_file "$ESA_KEY_FILE"
} > "$BODY"

PAYLOAD_HASH=$(sha256_file "$BODY")
ACS_DATE=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
NONCE=$(openssl rand -hex 16)
SIGNED_HEADERS='host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version'

# CanonicalRequest: METHOD, URI, query, canonical headers, signed names, payload hash.
CANONICAL="$TMP/canonical"
{
  printf 'POST\n/\n\n'
  printf 'host:%s\n' "$HOST"
  printf 'x-acs-action:SetCertificate\n'
  printf 'x-acs-content-sha256:%s\n' "$PAYLOAD_HASH"
  printf 'x-acs-date:%s\n' "$ACS_DATE"
  printf 'x-acs-signature-nonce:%s\n' "$NONCE"
  printf 'x-acs-version:2024-09-10\n'
  printf '\n%s\n%s' "$SIGNED_HEADERS" "$PAYLOAD_HASH"
} > "$CANONICAL"

CANONICAL_HASH=$(sha256_file "$CANONICAL")
SECRET_HEX=$(printf %s "$ALIYUN_ACCESS_KEY_SECRET" | od -An -v -tx1 | tr -d ' \n')
SIGNATURE=$(printf 'ACS3-HMAC-SHA256\n%s' "$CANONICAL_HASH" |
  openssl dgst -sha256 -mac HMAC -macopt "hexkey:$SECRET_HEX" | sed 's/^.*= //')
AUTH="ACS3-HMAC-SHA256 Credential=$ALIYUN_ACCESS_KEY_ID,SignedHeaders=$SIGNED_HEADERS,Signature=$SIGNATURE"

HTTP_CODE=$(curl -sS --connect-timeout 15 --max-time 60 \
  -o "$TMP/response.json" -w '%{http_code}' \
  -X POST "https://$HOST/" \
  -H "Host: $HOST" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'x-acs-action: SetCertificate' \
  -H 'x-acs-version: 2024-09-10' \
  -H "x-acs-date: $ACS_DATE" \
  -H "x-acs-signature-nonce: $NONCE" \
  -H "x-acs-content-sha256: $PAYLOAD_HASH" \
  -H "Authorization: $AUTH" \
  --data-binary "@$BODY") || { echo 'ESA API request failed' >&2; exit 1; }

case "$HTTP_CODE" in
  2??)
    if grep -q '"Id"[[:space:]]*:' "$TMP/response.json" &&
       ! grep -q '"Code"[[:space:]]*:' "$TMP/response.json"; then
      echo "ESA certificate upload/update accepted (HTTP $HTTP_CODE):"
      cat "$TMP/response.json"; printf '\n'
      # The official response contains a top-level string Id. Persist it only
      # when the operator has not pinned an ID in config.
      if [ -z "${ESA_CERT_ID_CONFIGURED:-}" ]; then
        NEW_ID=$(sed -n 's/.*"Id"[[:space:]]*:[[:space:]]*"\([^"\]*\)".*/\1/p' "$TMP/response.json" | head -n 1)
        case "$NEW_ID" in
          ''|*[!a-zA-Z0-9_-]*) echo 'Cannot safely parse returned certificate ID' >&2; exit 1 ;;
        esac
        printf '%s\n' "$NEW_ID" > "$STATE_ID_FILE.tmp"
        chmod 600 "$STATE_ID_FILE.tmp"
        mv -f "$STATE_ID_FILE.tmp" "$STATE_ID_FILE"
      fi
    else
      echo "ESA API returned an unexpected response (HTTP $HTTP_CODE):" >&2
      cat "$TMP/response.json" >&2; printf '\n' >&2
      exit 1
    fi
    ;;
  *)
    echo "ESA API error (HTTP $HTTP_CODE):" >&2
    cat "$TMP/response.json" >&2; printf '\n' >&2
    exit 1
    ;;
esac

同目录下创建配套的配置文件 esa-cert-sync.conf:

ALIYUN_ACCESS_KEY_ID='WWWNEKOPARAUK9981S5WFQGGDS'
ALIYUN_ACCESS_KEY_SECRET='ScsGTX690SLI6FS81F4fswgbj'
ESA_REGION='cn-hangzhou' # or ap-southeast-1 for Alibaba Cloud international account
ESA_SITE_ID='1145141919810'
ESA_CERT_ID=''
ESA_CERT_NAME='nekopara.uk'
ESA_CERT_FILE='/data/certs/nekopara.uk.pem'
ESA_KEY_FILE='/data/certs/nekopara.uk.key'

这里注意,ESA_SITE_ID 要在 ESA 控制台复制对应的站点的 ID。
证书的路径需要填写对应域名的实际证书路径,然后可以找之前申请过的证书放进去执行测试一下:

NyankoHost [/data/esa-certs-update]# ./esa-cert-sync.sh
ESA certificate upload/update accepted (HTTP 200):
{"RequestId":"08A9A49A-6672-26A7-98CA-FBAE666E05A9","Id":"baef5fv95d8888a1812a6633611063d2"}

如果返回结果类似上面这样,再检查一下确实多了一个自定义证书,说明成功了。
这其中的 baef5fv95d8888a1812a6633611063d2 可以填入之前配置文件的 ESA_CERT_ID,这是证书 ID,只能通过 API 接口返回,后续更新证书需要用这个。另外填写是可选项,脚本已经能自己处理和保存了。

NyankoHost [/data/esa-certs-update]# ls
esa-cert-sync.conf  esa-cert-sync.sh    state
NyankoHost [/data/esa-certs-update]# cat /data/esa-certs-update/state/cert-id
baef5fv95d8888a1812a6633611063d2

配置 ACME.sh 定期更新证书并推送

证书怎么签发可以看这篇文章:配置 ACME.sh 实现证书自动化签发
签发完成后,安装证书:

/root/.acme.sh/acme.sh --install-cert \
  -d nekopara.uk \
  --ecc \
  --key-file /data/certs/nekopara.uk.key \
  --fullchain-file /data/certs/nekopara.uk.pem \
  --reloadcmd "/data/esa-certs-update/esa-cert-sync.sh"

这样每次证书更新都会进行证书推送。
如果之前就已经安装过证书,补上需要新增的重载命令重新安装即可:

/root/.acme.sh/acme.sh --install-cert \
  -d example.com \
  --ecc \
  --key-file /data/certs/nekopara.uk.key \
  --fullchain-file /data/certs/nekopara.uk.pem \
  --reloadcmd "rc-service nginx reload && /data/esa-certs-update/esa-cert-sync.sh"

执行完成后,如果 ESA 的证书更新到新的证书了,那就没问题了。


如此一来,就可以让 ESA 托管的自定义证书自动更新了,Cloudflare 自己就可以更新证书,国内外 CDN 分流的架构也就完成了。

参与讨论