ACME.SH 多域名证书申请与部署的流程思路

在此之前,我一直生活在由 Cloudflare 打造的舒适圈中:完全不需要考虑 SSL 证书的部署与续期,即使在国内需要优化线路,我也只是使用 TCP 四层反代的方式,将证书交给 Cloudflare 管理。然而在逐步构建家庭服务器的过程中,证书终究还是绕不过的一环。在此之前,证书给我的印象是:繁琐。申请麻烦,部署困难,以及越来越短的证书有效期导致需要经常考虑续期问题,这些都是让我转向证书自管理的阻碍。

然而,在阅读本篇文章之后,你完全可以实现:

  • 在一次性的配置后,即可实现后续的全自动证书续期
  • 对于托管在不同平台的多个域名,仅需配置某一个平台的 API,即可实现全自动 DNS 验证
  • 在多台应用服务器上,仅需一个简单的 shell 脚本,配合定时任务,即可实现全自动证书部署

如果没有多域名,多台机器分离部署的需求,阅读本篇文章后,你仍然可以学到:

  • acme. sh 的证书申请, 自动化续期与部署方式
  • acme. sh 的调试方式
  • 证书申请中使用其他域名帮助进行 DNS 01 验证

提示

本篇教程仍然需要懂得基础的 Linux 操作方法与部分网络证书知识

思想设计 & 主体结构

整体架构分为三部分:

  • 证书的申请与签发(基于 acme.sh)
  • 证书的存储与分发(本篇中基于 Cloudflare KV 和 Cloudflare Workers,当然你可以选择更适合自己的存储源和分发源,如 OSSNginx,但是本篇暂不涉及)
  • 证书的部署(由一个简单的 shell 脚本与定时任务组成)

三个部分可以完全分离,并不需要放在同一台服务器或主机上。

对于多个不同的域名,则需要选择一个域名用于进行 DNS 01 认证(假设为 autorenew.com ),而其他域名只需一次性的 DNS 配置,将 _acme-challenge.example.com CNAME 到 _acme-challenge.autorenew.com (或者其他前缀)即可完成后续的验证与续期。

申请完成后,调用 --deploy-hook 脚本,将证书上传至 Cloudflare KV。后续续期后,覆盖上传新证书。

在客户端使用 shell 脚本,定期从远端下载证书并和本地证书对比更新,实现本地证书的自动更新续期。

信息

⚠️:当前架构并非本人原创,核心思想和灵感来源于 fernvenue 的證書的自我修養,和微林(vx. Link)的证书服务,在两个的基础上进行合并,改编与拓展,以适配个人需求。

如果没有特殊需求,微林(vx. Link)的证书服务则可以免费提供证书的自动申请与续期,而你要做的仅是在 DNS 服务商配置一段 CNAME 的 DNS 记录,以及在服务器上使用一段 shell 脚本自动更新被续期的证书。(微林打钱!!)但是目前来说,微林上的证书服务还有一些不完善,这促使我转向证书自管理,具体如下:

  • 证书的下载链接不提供 https,仅使用 http(经过论坛反馈目前已支持,可以使用 https://get.vx.link/ 来代替之前的 http 下载链接)
  • 目前尚不支持三级泛域名的证书申请(2025.07.07)

acme.sh 的安装

当前部分仅对 acme.sh 的安装一笔带过,更多高级配置请参考官方文档

shell
curl https://get.acme.sh | sh -s email=my@example.com #注意修改邮箱

邮箱填写并非强制,填写邮箱后会自动注册一个 acme 账户,用于后续异常通知,或违规问题。需要注意的是,如果你不想填邮箱,则需要删除 email=my@example.com ,否则acme 与 CA 会自动阻止类似于 @example.com 等无效邮箱的注册和申请证书。

在 acme.sh 中选择&配置 DNS 服务商

DNS 别名模式

假设我有多个不同的域名/子域名都需要申请证书,并且麻烦的是,这些域名都托管在不同的域名解析商中,如 DNSPOD,阿里云,华为云等。这意味着,如果分别使用 acme.sh 申请证书,并且还要保证自动续期,则需进行非常多的 API 配置,这同时也增加了很多风险。

但此时,我还有一个域名托管在 Cloudflare,该域名并不非常重要,并且 Cloudflare 的 API 配置简单而且可以精细权限控制,那么我就可以使用该域名进行其他所有域名的验证工作。

将需要申请证书的域名(假设为 example.com),创建 _acme-challenge.example.com 的 CNAME 记录,指向用于验证的,托管在 Cloudflare 的域名 _acme-challenge.autorenew.com ,一劳永逸。

这样配置可行的原理是,证书申请中的 DNS 01 验证可以跟随 CNAME 解析,所以只要最终被 CNAME 到的域名中的 TXT 配置正确,也可以通过 DNS 01 验证。

acme.sh 官方文档对此有更加详细的解释说明,参阅 DNS-alias-mode

Cloudflare API 配置

进入 Cloudflare 的后台,在 管理账户-账户 API 令牌 令牌处创建一个 API 令牌,权限仅勾选 DNS 区域的编辑。同时,进入域名配置页,在网页右侧找到 区域ID 并记录下来。

编辑 .acme.sh/account.conf 文件,在最后添加上:CF_Token="xxx"CF_Zone_ID="xxx",同时确保文件权限为 600.

acme.sh 的证书申请

接下来仅需一行命令进行证书的申请托管在 DNSPOD 的域名(假设为 dnspod.com )的泛域名证书:

shell
acme.sh --issue --dns dns_cf --challenge-alias "cloudflare.com`" -d "*.dnspod.com" --server letsencrypt
  • --challenge-alias 参数会自动选择传入域名的 _acme-challenge 前缀子域名进行验证
    • 如果想自定义前缀,则需使用 --domain-alias
      • 比如,我想设置 abc.cloudflare.com 子域而不是 _acme-challenge.cloudflare.com 专用于 DNS 验证,则需传入 --domain-alias "abc.cloudflare.com"
  • --server letsencrypt 选择在let‘s Encrypt 处申请证书
  • --dns 选择使用 cloudflare 的自动 dns 验证服务

对于托管在其他解析服务商的域名,则需要重复调用几次 --issue 来申请证书:

shell
acme.sh --issue --dns dns_cf --challenge-alias "cloudflare.com`" -d "*.aliyun.com" --server letsencrypt

acme.sh --issue --dns dns_cf --challenge-alias "cloudflare.com`" -d "*.hwcloud.com" --server letsencrypt

这里并不建议通过 -d 参数同时传入多个域名。-d 参数会使得 acme.sh 创建一个 SAN 多域名证书,包含 -d 传入的所有域名。这确实会更方便,但同时也增加了一些安全风险,所以这里我们不使用 SAN 证书。

acme.sh 申请调试

Let’s Encrypt 对证书申请频率的限制非常严格,但是在调试中却不可避免的需要多次申请证书。这时我们可以将 --server 中的 CA 改为 letsencryp_test。这会从远端申请到一个假证书专用于调试。

假证书的申请也并不是无限制的,但是会比真正的证书申请频率高几倍。

为什么不使用 --staging 参数?

  • 根据一些 GitHub 的 issue 说,--staging 有时仍然会获取到正式的证书。为了避免碰到限制,使用 letsencryp_test 作为 server 会更加稳妥

acme.sh 的自动上传

acme.sh 支持在部署的时候通过 --deploy-hook 的时候自动调用外部 shell 脚本,将其用来上传申请好的证书到存储源。

.acme.sh/deploy/ 目录下创建 cloudflare_kv.sh

shell
#!/bin/bash
# Cloudflare KV API 配置
CF_ACCOUNT_ID="xxx"
CF_API_TOKEN="xxx"
KV_NAMESPACE_ID="xxx"

cloudflare_kv_deploy() {
    _cd="$1"           # domain
    _key="$2"          # key file path
    _cert="$3"         # fullchain file path
    _ca="$4"           # ca file path
    _fullchain="$5"    # cert+ca fullchain path

    # 移除泛域名前缀 *.
    domain="${_cd#*.}"

    # 拼接 KV keys
    fullchain_key="${domain}:fullchain"
    privkey_key="${domain}:key"

    # ⚡ 检查私钥格式并转换为 PKCS#8(如有必要)
    if grep -q "BEGIN EC PRIVATE KEY" "$_key"; then
        echo "⚠️ 检测到 EC 私钥,正在转换为 PKCS#8 格式..."
        pkcs8_key=$(mktemp)
        openssl pkcs8 -topk8 -nocrypt -in "$_key" -out "$pkcs8_key"
        _key="$pkcs8_key"
        echo "✅ 私钥已转换为 PKCS#8 格式"
    else
        echo "ℹ️ 私钥已是 PKCS#8 或 RSA,无需转换"
    fi

    # 上传函数
    upload_kv() {
        local key=$1
        local file=$2
        local value=$(base64 < "$file")
        curl -s -o /dev/null -X PUT \
            -H "Authorization: Bearer $CF_API_TOKEN" \
            -H "Content-Type: text/plain" \
            --data "$value" \
            "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/storage/kv/namespaces/$KV_NAMESPACE_ID/values/$key"
    }

    # 上传证书和私钥
    upload_kv "$fullchain_key" "$_fullchain"
    upload_kv "$privkey_key" "$_key"

    echo "✅ 已上传 ${domain} 的证书和私钥到 Cloudflare KV"
}
  • 对于此处的 CF_API_TOKEN,则需要重新创建一个 Cloudflare API 令牌,仅分配权限为 Workers KV 存储 - 编辑
  • KV_NAMESPACE_ID 则是 KV 存储实例的 ID,创建后即可在 KV 名称的右侧看到

[!note] 同时,当前脚本会自动将原来的 EC 私钥转换为 PKCS #8 的格式,以换取更好的兼容性。KV 中的 keys 的格式为 {domain}:key, 如果 domain 为泛域名,则会自动移除前面的 *.

比如:*.example.com 在 KV 的 key 存储为 example.com,这会导致 *.example.comexample.com 的证书冲突,需要注意。

后续,chmod +x cloudflare_kv.sh 给脚本添加执行权限后,通过下面命令测试:

shell
acme.sh --deploy -d "*.dnspod.com" --deploy-hook cloudflare_kv
  • --deploy-hook 默认会在 .acme.sh/deploy/ 目录下寻找 shell 脚本,并且不支持子目录(不能调用 .acme.sh/deploy/custom/ 下的脚本)

执行完成后,在 KV 中检查是否已经成功上传证书,证书本身已经过 base64 编码。对于其他域名,仍然需重复执行多次上面的 --deploy。这样可以保证部署流程被写入 acme.sh, 这会让下次的自动续期自动上传续期后的证书到 KV 中。

[!note] 对于其他的存储源,须确保续期后的证书可以覆盖掉旧证书。Cloudflare KV 的默认逻辑即为覆盖,所以不需要考虑。

acme.sh 的自动续期

默认情况下,acme.sh 每天会自动检查是否有需要续期的域名,同时自动调用申请时的配置进行 renew,如 --dns--challenge-alias--server,以及部署时的自动上传脚本 --deploy-hook

测试可以使用 acme.sh renew -d '*.dnspod.com' --force 来观察自动续期时的情况,同样需要注意的是申请证书的频率限制非常严格,不要多次通过 force 强制续期测试。

证书的分发(Cloudflare Workers)

本篇的分发服务使用 Cloudflare Workers 实现,绑定 KV 后,在 Workers 中进行身份验证,域名区分等,可以简单的通过 GET 请求来下载证书以及私钥。

使用 Workers 来处理分发还有另一个好处:分发服务本身不用担心证书过期的问题,该服务本身的证书由 Cloudflare 管理。需要注意的就是 Workers 在国内的连接性并不太好,需要测试是否能成功下载。

js
export default {
  async fetch(request, env, ctx) {
    try {
      // 解析 URL 和请求头
      const requestUrl = new URL(request.url);
      const requestSearchParams = requestUrl.searchParams;
      const authHeader = request.headers.get("X-CERTIFICATE-KEY");

      // 校验 X-CERTIFICATE-KEY
      if (authHeader !== env.X_CERTIFICATE_KEY) {
        return new Response(
          JSON.stringify({ code: 401, message: "Unauthorized Request." }),
          {
            status: 401,
            headers: { "Content-Type": "application/json;charset=utf-8" },
          }
        );
      }

      // 获取 type 参数并小写化
      let requestCertificateType = requestSearchParams.get("type");
      if (!requestCertificateType) {
        return new Response(
          JSON.stringify({ code: 400, message: "Missing type parameter." }),
          {
            status: 400,
            headers: { "Content-Type": "application/json;charset=utf-8" },
          }
        );
      }
      requestCertificateType = requestCertificateType.toLowerCase();

      // 拼接 KV key(匹配 acme.sh 上传格式)
      // fullchain: home.meoware.cn:fullchain
      // key: home.meoware.cn:key
      const keySuffix = requestCertificateType === "fullchain" ? ":fullchain"
                     : requestCertificateType === "key"        ? ":key"
                     : null;

      if (!keySuffix) {
        return new Response(
          JSON.stringify({ code: 400, message: "Invalid type parameter." }),
          {
            status: 400,
            headers: { "Content-Type": "application/json;charset=utf-8" },
          }
        );
      }

      // KV Key 前缀,支持多域(从 ?domain 参数获取)
      const domainParam = requestSearchParams.get("domain");
      if (!domainParam) {
        return new Response(
          JSON.stringify({ code: 400, message: "Missing domain parameter." }),
          {
            status: 400,
            headers: { "Content-Type": "application/json;charset=utf-8" },
          }
        );
      }

      const kvKey = `${domainParam}${keySuffix}`;

      // 从 KV 获取证书或私钥
      const certificateResponse = await env.KV_SSL_CERTIFICATES.get(kvKey);
      if (certificateResponse === null) {
        return new Response(
          JSON.stringify({ code: 404, message: "Certificate Not Found." }),
          {
            status: 404,
            headers: { "Content-Type": "application/json;charset=utf-8" },
          }
        );
      }

      // Base64 解码
      const certificateResponseDecode = atob(certificateResponse);

      // 返回证书或私钥
      return new Response(certificateResponseDecode, {
        status: 200,
        headers: { "Content-Type": "text/plain;charset=utf-8" },
      });
    } catch (err) {
      console.error("Error handling request:", err);
      return new Response(
        JSON.stringify({ code: 500, message: "Internal Server Error." }),
        {
          status: 500,
          headers: { "Content-Type": "application/json;charset=utf-8" },
        }
      );
    }
  },
};
  • 需要在 网页后台-绑定 中绑定之前的 KV 存储源
  • 当前脚本会验证请求头中的 X_CERTIFICATE_KEY,再部署好 Workers 后,需在 网页后台-设置-变量和机密 中添加名为 X_CERTIFICATE_KEY 的环境变量。

请求格式为:

shell
curl -v -H "X-CERTIFICATE-KEY: xxxxx" \
  "https://ssl.workers.dev?domain=dnspod.com&type=fullchain"
  • type: 可以为 fullchain 和 key,分别对应证书本身和密钥
  • domain:对应域名,当前 JS 默认所有证书为泛域名,即传入 dnspod.com 会返回 *.dnspod.com 的证书和对应私钥

证书的部署

在客户端上,最后要做的就是证书部署,这部分很简单,仅需一个 shell 脚本。他的作用是:请求远端证书,并和本地的已有证书做对比。如果不一样代表证书已续期,更新本地证书并执行刷新命令:

shell
#!/bin/bash
# 注意:此脚本需要 curl 与 md5sum 命令,如果没有请提前安装

# 定义 URL 和对应的本地存储位置
declare -A urls_and_paths=(
   # 下面是一个例子,如果是在 nginx 中,您应该根据 nginx.conf 或者自定义的配置文件来填写参数
   ["https://ssl.workers.dev?domain=dnspod.com&type=key"]="/etc/nginx/ssl/dnspod.com/cert.key"
   ["https://ssl.workers.dev?domain=dnspod.com&type=fullchain"]="/etc/nginx/ssl/dnspod.com/cert.crt"
   # 一行一份,添加更多的 URL 和对应的本地存储位置
   # 请注意, Key 和 Crt 文件应该都一起更新
)

# ⚠️ 这里定义需要附加的 header(支持多个)
# 格式:("Header1: Value1" "Header2: Value2")
extra_headers=(
   "X-CERTIFICATE-KEY: xxxx"
)

# 定义特定的指令(如果要重启服务,可以使用 nginx -s reload)
command_to_execute="nginx -s reload"

# 定义变量来判断是否需要执行操作
execute_operation=false

# 拼接 curl 的 header 参数
curl_header_args=()
for header in "${extra_headers[@]}"; do
   curl_header_args+=("-H" "$header")
done

# 遍历所有 URL,并下载文件
for url in "${!urls_and_paths[@]}"; do
   local_path="${urls_and_paths[$url]}"
   
   # 下载远端文件到临时文件
   temp_file=$(mktemp)
   curl -s "${curl_header_args[@]}" -o "$temp_file" "$url"

   # 检查是否下载成功
   if [ $? -ne 0 ]; then
       echo "Warning: Failed to download $url"
       rm -f "$temp_file"
       continue
   fi

   # 计算远端文件和本地文件的 MD5 值
   remote_md5=$(md5sum "$temp_file" | awk '{print $1}')
   local_md5=$(md5sum "$local_path" 2>/dev/null | awk '{print $1}')

   echo "Remote MD5 for $url is $remote_md5 | Local MD5 is $local_md5"

   # 如果 MD5 值不同,则更新本地文件
   if [ "$remote_md5" != "$local_md5" ]; then
       echo "Updating file from $url to $local_path"
       mv "$temp_file" "$local_path"
       execute_operation=true
   else
       rm -f "$temp_file"
   fi
done

# 执行特定的指令,当有任意一个 URL 的文件发生改变时才执行
if [ "$execute_operation" = true ]; then
   eval "$command_to_execute"
fi

当前脚本基于微林的自动化更新脚本改编而来,增加了请求头配置, 有几行配置需要更改:

  • urls_and_paths 中左边为证书直链,右边则是本地存储的位置,确保有权限写入
  • extra_headers 添加请求直链时额外的认证请求头,在本篇教程中需要添加的只有 X-CERTIFICATE-KEY
  • command_to_execute 在证书更新完毕后需要执行的命令,如 nginx 则需要执行 nginx -s reload 刷新证书

将脚本存储在固定位置,添加执行权限,添加到计划任务:

shell
chmod +x vxSSLupdate.sh

(crontab -l 2>/dev/null; echo "0 0 * * * /path/to/vxSSLupdate.sh") | crontab -

References

Js in Python:使用 Python 编写 Cloudflare Workers 项目