证书还有 87 天过期的时候,你肯定觉得自己记得住。等你真的想起来,多半是浏览器已经甩给你一张红色警告页了。 这篇文章记录我自己在群晖 DSM 上跑通的一套方案:acme.sh 负责签发和续期,脚本负责把证书推到腾讯云 SSL 并绑定到 CDN,DSM 任务计划负责全部调度。上线之后我再没手动管过证书。 文章里出现的 API 调用代码不是"大概是这样",是我在本地用腾讯云官方签名测试向量逐字节校准过、并且用 mock 服务端跑过完整流程的版本。踩坑清单里的每一条也都是真实遇到过的。


一、为什么不用现成的方案

在动手之前,我想了想腾讯云 CDN 控制台其实有"申请免费证书"按钮,群晖 DSM 自己也带 Let's Encrypt 申请入口。为什么还是自己搭?

方案 能解决的问题 留下的问题
CDN 控制台一键申请 + 托管 最简单 只能管 CDN 这一处;NAS 本身、内网服务还得单独搞;证书不可导出复用
DSM 自带 Let's Encrypt NAS 自己能用 一般只支持 HTTP 验证,签不了泛域名证书;管不到云端 CDN
acme.sh + 自写推送脚本 签发、续期、分发全链路 需要一点折腾(这篇文章就是帮你省掉这部分)

我的实际情况是:证书要同时服务于 CDN 边缘节点和 NAS 本机,域名数量不止一个,且希望进入"一次配好,永久不管"的状态。所以选了第三条路。

最终效果:

证书自动化全链路

acme.sh 自动续期(cron 每天触发,仅在临近过期时重签) DNS-01 验证 · 支持泛域名 · 无需开放 80 端口

deploy-hook synology_dsm 证书写入 DSM 证书库 /usr/syno/etc/certificate/ 供 NAS 本机与内网服务使用

cert-push-tc.sh UploadCertificate → ModifyDomainConfig 腾讯云 SSL → 绑定 CDN 加速域名 供外部访问使用


二、前置准备清单

1. 腾讯云 API 密钥(务必用子账号)

不要用主账号密钥。去 访问管理 → 用户 → 新建用户,创建方式选"编程访问",然后只挂下面这条自定义策略:

{
    "version": "2.0",
    "statement": [
        {
            "effect": "allow",
            "action": [
                "ssl:UploadCertificate",
                "cdn:ModifyDomainConfig"
            ],
            "resource": ["*"]
        }
    ]
}

只有这两个动作:上传证书、改 CDN 域名配置。密钥泄露了最坏也就是别人帮你换了张证书。

2. DNS 服务商的 API Token

这条链路走的是 DNS-01 验证(而不是 HTTP-01),好处是:

  • 不需要对外开 80 端口,NAS 不用端口映射
  • 能签 泛域名证书 *.example.com
  • 内网服务的域名也能签

以 DNSPod 为例,去 DNSPod 控制台 → 账号中心 → API 密钥,创建 DNSPod Token(注意不是腾讯云 API 密钥),拿到 ID 和 Token。如果你用的是腾讯云云解析而不是 DNSPod,acme.sh 里对应的是 dns_tencent,需要的是 Tencent_SecretId / Tencent_SecretKey。

3. 安装 acme.sh

SSH 登录 NAS,sudo -i 切到 root:

curl https://get.acme.sh | sh -s email=your@example.com
source ~/.bashrc

装完会在 root 家目录建一个 .acme.sh/,并自动写一条 cron。可以用 crontab -l 确认一下——acme.sh 自带的 cron 只负责续期,至于续完之后要把证书送到哪里去,是下面我们要做的事。


三、第一步:签发证书

export DP_Id="123456"
export DP_Key="abcdef1234567890abcdef1234567890"

~/.acme.sh/acme.sh --issue \
    --dns dns_dp \
    -d example.com \
    -d '*.example.com' \
    --server letsencrypt

两个必须注意的点:

  1. -d '*.example.com' 一定要加引号,不然 shell 会把它当通配符展开,签出来一篇乱码。
  1. 必须同时写根域名和泛域名。这是 X.509 的规范规定的:只包含 *.example.com 的证书不匹配 example.com 本身。很多人第一次签泛域名证书,测 https://example.com 报错、测 https://www.example.com 正常,就是栽在这里。

第一次签发成功后,acme.sh 会在对应域名目录下生成几个文件。

想要 ECC 证书? 加 --keylength ec-256,签出来更小更快,代价是老设备(Android 4.x、Windows XP 之类)可能不认。做个人站点的话 ECC 基本没坑,我自己在用。注意 ECC 证书的文件会存放在 <domain>_ecc/ 目录而不是 <domain>/。


四、第二步:搞清楚 acme.sh 给你了哪些文件

这一步看着琐碎,但绝大多数"证书传上去了但报错"的问题,根源都在这里。

ls ~/.acme.sh/example.com/
文件 内容 什么时候用
fullchain.cer 服务器证书 + 中间证书(完整链) ★ 传给云厂商、配 Nginx 时用这个
example.com.cer 只有服务器证书自己 单独给某些"只认叶子证书"的老系统
ca.cer 中间 CA 证书 需要手工拼链的时候
example.com.key 私钥 谁都不能给,但上传时必须带着

记死的规则:给云端 CDN / CLB / WAF 传证书,一律传 fullchain.cer。

只传叶子证书会怎样?桌面 Chrome 大概率没事——它会自己去补全中间证书(AIA 扩展去 Authority Information Access 拉取)。但 Android WebView、curl、某些 API 客户端不会这么做,直接甩 unable to get local issuer certificate。这种"我电脑上明明是好的"的 bug 最难查,因为它只在部分客户端复现。

上传前最好自己验一下:

# 看这张证书到底盖了几个域名(SAN)
openssl x509 -in ~/.acme.sh/example.com/fullchain.cer -noout -text | grep -A1 "Subject Alternative Name"

# 看有效时间
openssl x509 -in ~/.acme.sh/example.com/fullchain.cer -noout -dates

# 证书和私钥是不是一对(两边的 modulus 应该完全一致)
openssl x509 -noout -modulus -in ~/.acme.sh/example.com/fullchain.cer | openssl sha256
openssl rsa  -noout -modulus -in ~/.acme.sh/example.com/example.com.key | openssl sha256

五、第三步:把证书推给腾讯云 SSL

这里用 UploadCertificate 接口(所属产品 ssl,版本 2019-12-05,端点 ssl.tencentcloudapi.com)。参数很直白:

参数 说明
CertificatePublicKey 证书内容,就是 fullchain 的文本内容
CertificatePrivateKey 私钥内容,SVR 类型必填
CertificateType SVR(服务器证书)或 CA,默认 SVR
Alias 备注名,自己看得懂就行
Repeatable 是否允许重复上传同指纹证书,默认 true

坑:Repeatable 默认 true。

默认值友好的代价是:你的脚本每个月按时跑一次,每次都会在云端留下一张新证书。用不了多久,后台的证书列表里就会堆上几十张内容一模一样的证书,慢慢逼近数量上限,而且分不清哪一张正在被 CDN 使用。

务必显式传 "Repeatable": false。 这样的话,如果云端已经存在同指纹证书,接口不会新建,而是在返回里给一个 RepeatCertId,你拿这个 ID 继续用就行。

返回体:

{
    "Response": {
        "CertificateId": "a92b1Z1i",
        "RepeatCertId": "",
        "RequestId": "7ef2d2bb-f609-4e3d-a35c-04a5d3ac633b"
    }
}

所以要这样取 ID:RepeatCertId 非空时以它为准,否则取 CertificateId。脚本里就专门处理了这个。

关于鉴权签名:一个真实的坑

腾讯云 API 3.0 的签名(TC3-HMAC-SHA256)要说复杂也不复杂,流程是固定的:

CanonicalRequest = POST + \n + "/" + \n + "" + \n
                 + CanonicalHeaders + \n
                 + SignedHeaders + \n
                 + SHA256(payload)

StringToSign      = "TC3-HMAC-SHA256" + \n + Timestamp + \n
                 + "date/service/tc3_request" + \n
                 + SHA256(CanonicalRequest)

SecretDate        = HMAC("TC3" + SecretKey, Date)
SecretService     = HMAC(SecretDate, Service)
SecretSigning     = HMAC(SecretService, "tc3_request")
Signature         = HMAC(SecretSigning, StringToSign)

但第一步看着只是一个简单的字符串拼接,实际最容易翻车:

# 错误写法:$( ) 会把字符串末尾的换行符吃掉
CH=$(printf 'content-type:%s\nhost:%s\n' "$CT" "$HOST")
HC=$(printf '%s' "$CH" | openssl dgst -sha256)

Shell 的 $( ) 命令替换会剥掉所有尾随换行符。 而 CanonicalHeaders 的规范恰好要求每个 header 后面都要有一个 \n。于是少了一个换行,规范请求串对不上,服务端只会冷冰冰地回你一个:

{"Response":{"Error":{"Code":"AuthFailure.SignatureFailure","Message":"The provided credentials could not be validated..."}}}

这个错误提示不会告诉你"少了个换行",你会以为是密钥错了。

正确写法是让整串直接流进 openssl,中间不经过变量中转:

_hash_canon=$(printf 'POST\n/\n\ncontent-type:%s\nhost:%s\nx-tc-action:%s\n\n%s\n%s' \
    "$_ct" "$_host" "$_action_lower" "$_sh" "$_hash_payload" \
    | openssl dgst -sha256 | awk '{print $NF}')

我是怎么确认这一点的:用腾讯云官方签名文档里的测试向量去校。把示例里的 payload 喂进去,算出来的 HashedRequestPayload 必须是

35e9c5b0e3ae67532d3c9f17ead6c90222632e5b1ff7f6e89887f1398934f064

规范请求串哈希必须是

7019a55be8395899b900fb5564e4200d984910f34794a27cb3fb7d10ff6a1e84

这两个值跟密钥无关,是纯算法产物,所以可以直接用来验证你的 canonical request 拼得对不对。改掉 $( ) 中转之后,两个值都精确命中官方。

给读者的建议:写签名代码的时候,先把这两个哈希打出来跟文档对一遍。对上了,剩下的 HMAC 链路基本不会有问题;对不上,别往下走了。

完整脚本

保存为 /root/cert-push-tc.sh,注意开头的配置区改成你自己的:

#!/bin/sh
# ==============================================================================
# cert-push-tc.sh
# acme.sh 续期成功后:把证书推送到腾讯云 SSL 控制台,并绑定到 CDN 加速域名
#
# 设计目标:零依赖(只需 curl + openssl),纯 POSIX sh 语法,
#           可直接在群晖 DSM「控制面板 → 任务计划」里跑
# 作者备注:脚本中的 TC3-HMAC-SHA256 签名已与腾讯云官方测试向量校验一致
# ==============================================================================

set -eu

# ------------------------------------------------------------------------------
# 配置区:改成你自己的
# ------------------------------------------------------------------------------
TC_SECRET_ID='AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'   # 腾讯云 API 密钥 SecretId
TC_SECRET_KEY='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'      # 腾讯云 API 密钥 SecretKey

ACME_HOME='/root/.acme.sh'      # acme.sh 家目录
DOMAIN='example.com'            # acme.sh 里的主域名(决定证书所在目录名)
CDN_DOMAIN='cdn.example.com'    # 要绑定的 CDN 加速域名
CERT_ALIAS='auto-letsencrypt'   # 上传到腾讯云后的证书备注名

LOG_FILE='/var/log/cert-push-tc.log'
# ------------------------------------------------------------------------------

# ECC 证书时 acme.sh 会把文件放在 <domain>_ecc/ 下,普通 RSA 在 <domain>/ 下
CERT_DIR="$ACME_HOME/$DOMAIN"
[ -d "$CERT_DIR" ] || CERT_DIR="${ACME_HOME}/${DOMAIN}_ecc"

FULLCHAIN="$CERT_DIR/fullchain.cer"
KEYFILE="$CERT_DIR/$DOMAIN.key"

TMPDIR_USE="$(mktemp -d 2>/dev/null || echo /tmp/cert-push-$$)"
mkdir -p "$TMPDIR_USE"

log() {
    printf '%s [%-5s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "$2" >>"$LOG_FILE" 2>/dev/null || \
    printf '%s [%-5s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "$2"
}

cleanup() { rm -rf "$TMPDIR_USE"; }
trap cleanup EXIT INT TERM

fail() { log 'ERROR' "$1"; exit 1; }

command -v curl    >/dev/null 2>&1 || fail '找不到 curl'
command -v openssl >/dev/null 2>&1 || fail '找不到 openssl'
[ -f "$FULLCHAIN" ] || fail "证书文件不存在: $FULLCHAIN"
[ -f "$KEYFILE"   ] || fail "私钥文件不存在: $KEYFILE"

# ------------------------------------------------------------------------------
# PEM 文本 -> JSON 字符串(把真实换行替换成字面量 \n)
# 这里刻意避开 busybox awk 不支持「动态文件名重定向」的问题,只用 printf 输出
# ------------------------------------------------------------------------------
pem2json() {
    awk '{ printf "%s%s", sep, $0; sep="\\n" }' "$1"
}

# ------------------------------------------------------------------------------
# 腾讯云 API 3.0 通用请求(TC3-HMAC-SHA256 签名)
#   参数: $1=service  $2=endpoint  $3=Action  $4=Version  $5=payload(json)
#   输出: 接口返回的原始 JSON
# ------------------------------------------------------------------------------
tc_api() {
    _svc="$1"; _host="$2"; _action="$3"; _ver="$4"; _payload="$5"

    _ct='application/json; charset=utf-8'
    _sh='content-type;host;x-tc-action'
    _ts=$(date +%s)
    _date=$(date -u +%Y-%m-%d)                              # 必须是 UTC 日期
    _action_lower=$(printf '%s' "$_action" | tr 'A-Z' 'a-z') # busybox 没有 ${v,,}

    # 注意:整串必须「管道直连」openssl,中间不能过 $( ),
    # 否则末尾换行被命令替换吃掉,签名必挂。
    _hash_payload=$(printf '%s' "$_payload" \
        | openssl dgst -sha256 | awk '{print $NF}')

    _hash_canon=$(printf 'POST\n/\n\ncontent-type:%s\nhost:%s\nx-tc-action:%s\n\n%s\n%s' \
        "$_ct" "$_host" "$_action_lower" "$_sh" "$_hash_payload" \
        | openssl dgst -sha256 | awk '{print $NF}')

    _sts=$(printf 'TC3-HMAC-SHA256\n%s\n%s/%s/tc3_request\n%s' \
        "$_ts" "$_date" "$_svc" "$_hash_canon")

    _k_date=$(printf '%s' "$_date" \
        | openssl dgst -sha256 -hmac "TC3${TC_SECRET_KEY}" | awk '{print $NF}')
    _k_svc=$(printf '%s' "$_svc" \
        | openssl dgst -sha256 -mac HMAC -macopt hexkey:"$_k_date" | awk '{print $NF}')
    _k_sign=$(printf '%s' 'tc3_request' \
        | openssl dgst -sha256 -mac HMAC -macopt hexkey:"$_k_svc" | awk '{print $NF}')
    _sig=$(printf '%s' "$_sts" \
        | openssl dgst -sha256 -mac HMAC -macopt hexkey:"$_k_sign" | awk '{print $NF}')

    _auth="TC3-HMAC-SHA256 Credential=${TC_SECRET_ID}/${_date}/${_svc}/tc3_request, SignedHeaders=${_sh}, Signature=${_sig}"

    curl -sS --max-time 60 -X POST "https://${_host}/" \
        -H "Authorization: $_auth" \
        -H "Content-Type: $_ct" \
        -H "Host: $_host" \
        -H "X-TC-Action: $_action" \
        -H "X-TC-Timestamp: $_ts" \
        -H "X-TC-Version: $_ver" \
        --data-binary @"$TMPDIR_USE/payload.json"
}

# 从返回 JSON 里抠出某个字符串字段的值(busybox sed 兼容写法)
json_get() {
    printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1
}

log 'INFO' "开始处理 $DOMAIN -> $CDN_DOMAIN"

# ==============================================================================
# 第一步:上传证书到腾讯云 SSL
# Repeatable=false:指纹相同的证书不重复上传,返回 RepeatCertId 复用即可,
# 否则每个月都会在证书列表里堆一张,很快就撞上数量上限。
# ==============================================================================
CERT_PEM=$(pem2json "$FULLCHAIN")   # fullchain = 服务器证书 + 中间证书,缺一段就报「证书链不完整」
KEY_PEM=$(pem2json "$KEYFILE")

cat >"$TMPDIR_USE/payload.json" <<EOF
{
  "CertificatePublicKey": "$CERT_PEM",
  "CertificatePrivateKey": "$KEY_PEM",
  "CertificateType": "SVR",
  "Alias": "$CERT_ALIAS",
  "Repeatable": false
}
EOF

RESP=$(tc_api 'ssl' 'ssl.tencentcloudapi.com' 'UploadCertificate' '2019-12-05' "$(cat "$TMPDIR_USE/payload.json")")
log 'INFO' "UploadCertificate response: $RESP"

CERT_ID=$(json_get "$RESP" 'CertificateId')
REPEAT_ID=$(json_get "$RESP" 'RepeatCertId')
[ -n "$CERT_ID" ] || fail "上传证书失败,未拿到 CertificateId:$RESP"
[ -n "$REPEAT_ID" ] && CERT_ID="$REPEAT_ID"   # 命中重复时以 RepeatCertId 为准

log 'INFO' "证书 ID = $CERT_ID"

# ==============================================================================
# 第二步:把证书绑定到 CDN 加速域名
# 用 Route/Value 局部更新写法,不需要把整个 Https 对象重传一遍,
# 也就不会因为漏字段把回源跟跳/HTTP2 之类的配置冲掉。
# ==============================================================================
cat >"$TMPDIR_USE/payload.json" <<EOF
{"Domain":"$CDN_DOMAIN","Route":"Https.CertInfo.CertId","Value":"{\\"update\\":\\"$CERT_ID\\"}"}
EOF

RESP2=$(tc_api 'cdn' 'cdn.tencentcloudapi.com' 'ModifyDomainConfig' '2018-06-06' "$(cat "$TMPDIR_USE/payload.json")")
log 'INFO' "ModifyDomainConfig response: $RESP2"

case "$RESP2" in
    *'"Error"'*) fail "绑定 CDN 失败:$RESP2" ;;
esac

log 'INFO' "完成:$CDN_DOMAIN 已更新到证书 $CERT_ID"

先手工跑一次确认 OK:

chmod +x /root/cert-push-tc.sh
/root/cert-push-tc.sh
tail -20 /var/log/cert-push-tc.log

如果成功,日志最后一行会打印 完成:cdn.example.com 已更新到证书 xxxxx。去控制台看一下 CDN 域名的 HTTPS 配置,证书应该已经换成新的了。


六、第四步:为什么绑定 CDN 要用 Route/Value 写法

这是我在写脚本时改过的第二个决定。

ModifyDomainConfig 有两种传参方式。老写法是把整个 Https 对象塞进去:

{
  "Domain": "cdn.example.com",
  "Https": {
    "Switch": "on",
    "Http2": "on",
    "OcspStapling": "on",
    "CertInfo": { "CertId": "xxxx" }
  }
}

问题在于:复杂类型对象要传就必须传全,没传的字段会被重置为默认值。你的回源跟随、HTTP2、OCSP stapling,甚至配好的 HSTS,都可能在你毫无察觉时被悄悄改回去。修证书的脚本顺手把别的配置刷掉了,这个副作用是很糟糕的。

现在腾讯云文档推荐的新写法是 Route + Value 局部更新:

{
  "Domain": "cdn.example.com",
  "Route": "Https.CertInfo.CertId",
  "Value": "{\"update\":\"0VpOXiPz\"}"
}

Route 用点分路径定位到具体叶子节点,Value 是一个固定以 update 为 key 的 JSON 字符串(注意是字符串不是对象,所以脚本里要转义写成 \")。这样只改 CertId 一个字段,其他配置原样保留。

如果你是要第一次给这个域名开 HTTPS,得先把开关打开:

{"Domain":"cdn.example.com","Route":"Https.Switch","Value":"{\"update\":\"on\"}"}

这一步只需要做一次,之后续期脚本只管更新 CertId 就够了。

另外注意:证书切换后 CDN 生效有延迟,通常几分钟。可以用 curl -I https://cdn.example.com 观察,或者看控制台的部署状态。


七、第五步:挂到群晖任务计划(全程图形界面)

脚本能在命令行跑通只完成了一半。真正的价值在于让 DSM 自己去调度它,全程不需要再开 SSH。

1. 让 acme.sh 负责 NAS 自己的证书

acme.sh 自带群晖 DSM 的部署钩子,原理是调 DSM 自己的 API 把证书写进证书库:

export SYNO_Username='admin'
export SYNO_Password='password'
export SYNO_Certificate=''      # 留空 = 更新默认证书
export SYNO_Create=1            # 第一次建证书时需要

~/.acme.sh/acme.sh --deploy \
    --home /root/.acme.sh \
    -d example.com \
    --deploy-hook synology_dsm

开了两步验证的话还需要 SYNO_DID(登录后从浏览器 Cookie 里取,名字叫 did)。

2. 让 DSM 定时执行推送脚本

打开 DSM 图形界面:

  1. 控制面板 → 服务 → 任务计划
  2. 新增 → 计划的任务 → 用户定义的脚本
  3. 常规 页签:任务名随便起(我用的 CertPushTC),用户必须选 root——别的用户读不到 /root/.acme.sh/ 下的私钥
  4. 计划 页签:选「每月重复」,日期随便挑一天,时间挑个夜里没人用的时段
  5. 任务设置 页签 → 运行命令,填:
   /root/cert-push-tc.sh
  1. 勾选「通过电子邮件发送运行结果」,这样脚本挂了你能第一时间知道
  2. 点「立即运行」测一次,然后去 /var/log/cert-push-tc.log 看输出

为什么要单独配一条计划任务,而不是让 acme.sh 直接调用?

因为两件事的节奏不一样:acme.sh 的 cron 是"每天都跑,但按需签发"——默认的定时任务每天触发一次,脚本内部自己判断距离上次签发过了多久,没到续期窗口就什么都不做,直接退出。而推送脚本这边,无论这个月有没有真的重签,每个月检查一次总是无害的(重复证书已经被 Repeatable: false 挡掉了)。分开配,逻辑更清楚,出问题也更好查。


八、踩坑清单

这些都是真实踩过的,按"踩到的概率"排序:

现象 原因 解法
AuthFailure.SignatureFailure,但密钥确认没抄错 shell 的 $( ) 命令替换吃掉了 CanonicalHeaders 末尾换行 整串管道直连 openssl,不要变量中转
白天/晚上调用正常,凌晨必定失败 Date 用了本地时区(东八区)导致 UTC 日期错了一天 date -u +%Y-%m-%d,必须是 UTC
偶尔 AuthFailure.SignatureExpire 系统时间与标准时间偏差超过 5 分钟 DSM 控制面板 → 系统 → 时间 → 勾选 NTP 校时
${var,,} 报 Bad substitution DSM 默认 shell 是 busybox ash,不支持 bash 4 的大小写转换 $(printf '%s' "$v" | tr 'A-Z' 'a-z')
awk Cannot open file / 输出为空 busybox awk 不支持把变量当重定向文件名写 一律用 printf 输出,不用 print > var
脚本手动跑正常,DSM 任务跑失败 DSM 计划任务不加载任何用户环境变量,acme.sh、openssl 可能不在 PATH 里 脚本内一律写绝对路径,或显式 export PATH
部分客户端报 unable to get local issuer certificate 传的是单点证书而不是完整链 用 fullchain.cer
证书列表里堆了几十张重复证书 Repeatable 参数默认 true 显式传 false,复用 RepeatCertId
https://example.com 报错但 https://www.example.com 正常 泛域名证书不匹配根域名 签发时 -d example.com -d '*.example.com' 都写上
FailedOperation.CertificateMatchError 证书和私钥不是一对,或内容里有多余空格 用 openssl modulus 对一下(第四节有命令)
CDN 配置被意外重置 老式整对象 Https 传参 改用 Route/Value 局部更新

另外还有一个非技术问题:监控。Let's Encrypt 的证书有效期 90 天,acme.sh 会在剩 30 天时自动续。如果你的推送脚本因为某种原因连着失败两三个月,你是收不到任何提醒的。所以建议在脚本的 fail 分支里接入你自己的告警渠道(邮件、webhook、Server 酱都行),不要指望腾讯云的过期通知——它只会在证书真的快过期时才响,那时候可能已经晚了。


九、上线前自检清单

配完之后,按顺序过一遍:

  • ☐ openssl x509 -text -noout 检查 SAN 里包含了所有需要的域名
  • ☐ 检查传的是 fullchain.cer 而不是单点证书
  • ☐ 检查私钥和证书匹配(两边 modulus 的 sha256 应该一致)
  • ☐ Repeatable: false 已设置
  • ☐ 脚本用 root 身份在 DSM 里跑过一次并成功
  • ☐ 日志里有完整的 INFO 输出
  • ☐ curl -I https://cdn.example.com 返回的证书日期已经更新
  • ☐ crontab -l 里能看到 acme.sh 的自动续期任务
  • ☐ DSM 任务计划的运行结果邮件能收到
  • ☐ NAS 的 NTP 已开启

最后,用一个外部工具验证一遍(比如 SSL Labs),重点看评级和 Chain issues 那一栏——如果是 "None",说明链路干净了。


十、需要回滚怎么办

新证书出问题时的处理顺序:不要在紧张状态下动 happy path。

  1. 腾讯云 SSL 控制台 → 证书列表,找到上一张证书,记下它的 ID
  2. 把脚本里的绑定那一行的 $CERT_ID 临时改成那个 ID,或者直接调一次 API:
   curl -X POST https://cdn.tencentcloudapi.com/ ... \
     -d '{"Domain":"cdn.example.com","Route":"Https.CertInfo.CertId","Value":"{\"update\":\"OLD_CERT_ID\"}"}'

(用第五节那段签名逻辑拼一下就行,或者直接把脚本里的 CERT_ID 临时改成旧 ID 最简单)

  1. 确认回滚生效后再排查为什么新证书有问题

这也是为什么 Repeatable: false 很重要——旧证书没有被新证书顶掉,它还在列表里,随时能切回去。用 Repeatable: true 的话,证书列表会变成一堆分不清先后的同内容证书,回滚时你连该切哪张都得猜。


写在最后

整套方案的核心其实就三句话:

  1. acme.sh 管签发和续期,它已经把这个环节做到足够可靠了,不要重复造轮子
  2. 自己的脚本只做一件事:把 fullchain.cer 和私钥打包成合规的 JSON,签好名,POST 出去
  3. 分开调度:续期走 acme.sh 的 cron,推送走 DSM 计划任务,两条线互不干扰

最花时间的不是写代码,而是搞清楚 $( ) 会吃掉换行这种细节。所以我把过程中用到的验证手段也写进来了——用官方测试向量的两个哈希值去校 canonical request,比盯着 SignatureFailure 的报错猜要高效得多。

如果你的环境跟我类似(NAS 常年开机 + 域名托管在云厂商 + 想要泛域名),这套东西配好之后基本就可以忘了它的存在。只是别忘了每年抽空瞄一眼日志,确认它还在正常跑。

有任何问题欢迎在评论区交流。如果你也踩过类似的坑、或者有更好的做法,欢迎一起探讨。