证书还有 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 本机,域名数量不止一个,且希望进入"一次配好,永久不管"的状态。所以选了第三条路。
最终效果:
二、前置准备清单
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
两个必须注意的点:
-d '*.example.com'一定要加引号,不然 shell 会把它当通配符展开,签出来一篇乱码。
- 必须同时写根域名和泛域名。这是 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 图形界面:
- 控制面板 → 服务 → 任务计划
- 新增 → 计划的任务 → 用户定义的脚本
- 常规 页签:任务名随便起(我用的
CertPushTC),用户必须选root——别的用户读不到/root/.acme.sh/下的私钥 - 计划 页签:选「每月重复」,日期随便挑一天,时间挑个夜里没人用的时段
- 任务设置 页签 → 运行命令,填:
/root/cert-push-tc.sh
- 勾选「通过电子邮件发送运行结果」,这样脚本挂了你能第一时间知道
- 点「立即运行」测一次,然后去
/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。
- 腾讯云 SSL 控制台 → 证书列表,找到上一张证书,记下它的 ID
- 把脚本里的绑定那一行的
$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 最简单)
- 确认回滚生效后再排查为什么新证书有问题
这也是为什么 Repeatable: false 很重要——旧证书没有被新证书顶掉,它还在列表里,随时能切回去。用 Repeatable: true 的话,证书列表会变成一堆分不清先后的同内容证书,回滚时你连该切哪张都得猜。
写在最后
整套方案的核心其实就三句话:
- acme.sh 管签发和续期,它已经把这个环节做到足够可靠了,不要重复造轮子
- 自己的脚本只做一件事:把
fullchain.cer和私钥打包成合规的 JSON,签好名,POST 出去 - 分开调度:续期走 acme.sh 的 cron,推送走 DSM 计划任务,两条线互不干扰
最花时间的不是写代码,而是搞清楚 $( ) 会吃掉换行这种细节。所以我把过程中用到的验证手段也写进来了——用官方测试向量的两个哈希值去校 canonical request,比盯着 SignatureFailure 的报错猜要高效得多。
如果你的环境跟我类似(NAS 常年开机 + 域名托管在云厂商 + 想要泛域名),这套东西配好之后基本就可以忘了它的存在。只是别忘了每年抽空瞄一眼日志,确认它还在正常跑。
有任何问题欢迎在评论区交流。如果你也踩过类似的坑、或者有更好的做法,欢迎一起探讨。

Comments NOTHING