一次真实的两站证书续期:Windows nginx + 七牛 CDN 全流程与 7 个坑
环境:Windows Server 2022 / nginx 1.21.3(NSSM 注册为服务)/ Node.js 22 / acme-client 5.x / 阿里云 DNS / 七牛云 CDN
对象:
chat.xutongbao.top(自建 nginx)、static.xutongbao.top(七牛 CDN)时间:2026-10-17,两张证书同日到期(10-18),一条命令签发的计划最终跑通
一、背景:两张证书,两个部署位置
手上的两个子域名,证书同一天到期:
| 域名 | 部署位置 | 说明 |
|---|---|---|
| chat.xutongbao.top | 阿里云 Windows + nginx | 直接绑证书文件 |
| static.xutongbao.top | 七牛云 CDN | 证书上传到 CDN,再绑定域名 |
需求也很明确:
- 不用自动续期(保持手动可控);
- 但要可复现------半年后再来一次,照着做就能完成;
- 尽量脚本化,少点控制台。
于是有了这次"从零到上线"的完整折腾,也踩了 7 个值得记录的坑。
二、环境与准备
2.1 工具
bash
# Node.js(用 acme-client 调 Let's Encrypt)
node -v # v22.x
npm i acme-client
# 本机/服务器上有 openssl(验证证书)
openssl version
2.2 阿里云 DNS 的 AccessKey
DNS-01 验证需要往域名里写 TXT 记录,所以要一个能操作 DNS 的 AccessKey。
安全做法:不要用主账号 AK,去 RAM 建子账号,只授予:
AliyunDNSFullAccess(简单)- 或自定义策略,只允许
xutongbao.top的AddDomainRecord/DeleteDomainRecord/DescribeDomainRecords
把 AK 放在本机文件里(两行),程序读取,不写日志、不回显:
C:\AstrBot\aliyun-ak.txt
第 1 行:AccessKeyId
第 2 行:AccessKeySecret
2.3 申请思路
- 用 DNS-01:两个域名都在 DNS 解析商手上,不依赖 80 端口;
- 用
acme-client直接调 ACME 协议:可控、可日志、可重试; - 证书 90 天,到期前手动跑一次即可(用户明确不要自动续期)。
三、签发:acme-client + 阿里云 DNS(DNS-01)
3.1 正确调用顺序
js
const acme = require('acme-client')
const fs = require('fs')
const client = new acme.Client({
directoryUrl: acme.directory.letsencrypt.production,
accountKey: fs.readFileSync('C:/AstrBot/acme/account.key', 'utf8'),
})
// 首次需要注册账号(之后复用 account.key 即可)
await client.createAccount({
termsOfServiceAgreed: true,
contact: ['mailto:you@example.com'],
})
// 1) 创建订单
const order = await client.createOrder({
identifiers: [{ type: 'dns', value: domain }],
})
// 2) 取授权与 dns-01 challenge
const authzs = await client.getAuthorizations(order)
const ch = authzs[0].challenges.find((c) => c.type === 'dns-01')
// 3) ★ 关键:拿到 TXT 记录值
const txtValue = await client.getChallengeKeyAuthorization(ch)
★ 坑 1:getChallengeKeyAuthorization() 已经返回了摘要值
网上很多示例写的是:
js
const keyAuth = await client.getChallengeKeyAuthorization(ch)
const txtValue = crypto.createHash('sha256').update(keyAuth).digest('base64url')
在 acme-client 5.x 里,对 dns-01 这是错的。
看它的源码(src/client.js):
js
if (challenge.type === 'dns-01') {
return createHash('sha256').update(result).digest('base64url')
}
也就是说,返回值本身就是 TXT 记录值(base64url(sha256(keyAuthorization)))。
我又做了一次 sha256,结果:
Incorrect TXT record "xxxx" found at _acme-challenge.chat.example.com
Let's Encrypt 明确告诉你"记录值不对",但你怎么看都觉得自己按文档写的------这就是最容易浪费半小时的地方。
正确写法:直接用返回值。
js
const txtValue = await client.getChallengeKeyAuthorization(ch)
await addDnsTxt('_acme-challenge.chat', txtValue)
3.2 写 TXT 记录(阿里云 DNS API)
调用 AddDomainRecord:
DomainName : xutongbao.top
RR : _acme-challenge.chat
Type : TXT
Value : <txtValue>
TTL : 600
★ 坑 2:Node 的 dns.Resolver 只认 IP,不认域名
为了让验证不受公共 DNS 缓存影响,我想直接查权威 NS:
js
const r = new dns.Resolver()
r.setServers(['dns27.hichina.com', 'dns28.hichina.com'])
直接报:
Invalid IP address: dns27.hichina.com
解法:先把 NS 域名解析成 IP,再 setServers。
js
const ips = []
for (const host of ['dns27.hichina.com', 'dns28.hichina.com']) {
const list = await dns.promises.resolve4(host)
ips.push(...list)
}
const r = new dns.Resolver()
r.setServers(ips)
3.3 校验
★ 坑 3:不要调用 verifyChallenge()
我一开始按文档写:
js
await client.verifyChallenge(authz, ch)
await client.completeChallenge(ch)
await client.waitForValidStatus(ch)
结果脚本卡死在第一步,日志停在"TXT 已写入"。
原因:verifyChallenge() 对 dns-01 会做一次本地 DNS 自查,在某些环境下这个查询会长时间挂起。
解法:跳过它,直接通知 Let's Encrypt 去校验。
js
await client.completeChallenge(ch)
await client.waitForValidStatus(ch)
3.4 生成密钥与 CSR(注意:七牛要 RSA!)
js
// chat.xutongbao.top → ECDSA(体积小、握手快)
const privKey = await acme.crypto.createPrivateEcdsaKey()
// static.xutongbao.top → 必须 RSA 2048
// 原因:七牛 CDN 不接受 ECDSA,上传时报"https证书解码失败"
const privKey2 = await acme.crypto.createPrivateRsaKey(2048)
const csr = await acme.crypto.createCsr(
{ commonName: domain, altNames: [domain] },
privKey
)
const finalized = await client.finalizeOrder(order, csr[1])
const certPem = await client.getCertificate(finalized)
fs.writeFileSync(domain + '.fullchain.pem', certPem)
fs.writeFileSync(domain + '.key.pem', privKey)
★ 坑 4:长任务会被"会话"带走
这个坑比较特殊:脚本在我的自动化会话里跑,会话一结束,子进程就被杀掉,日志永远停在中间某一步(而且看起来像"卡住")。
解法:用 Windows 计划任务跑,彻底脱离会话。
powershell
schtasks /create /tn acme_cert /tr "C:\AstrBot\acme\run.bat" /sc once /st 23:59 /rl highest /f
schtasks /run /tn acme_cert
schtasks /delete /tn acme_cert /f
同时日志必须用同步写文件:
js
const log = (s) => fs.appendFileSync(
'issue.log',
new Date().toISOString().slice(11, 19) + ' ' + s + '\n'
)
用
console.log重定向到文件会块缓冲,进度看起来"不动",非常误导。
四、部署到 Windows nginx
4.1 备份 + 覆盖
powershell
# 服务器上先备份
copy /Y C:\temp\ssl\chat.xutongbao.top\chat.xutongbao.top.crt ^
C:\temp\ssl\chat.xutongbao.top\chat.xutongbao.top.crt.bak-20261017
copy /Y C:\temp\ssl\chat.xutongbao.top\chat.xutongbao.top_ecc.key ^
C:\temp\ssl\chat.xutongbao.top\chat.xutongbao.top_ecc.key.bak-20261017
# 覆盖新证书
copy /Y new-fullchain.pem C:\temp\ssl\chat.xutongbao.top\chat.xutongbao.top.crt
copy /Y new-key.pem C:\temp\ssl\chat.xutongbao.top\chat.xutongbao.top_ecc.key
★ 坑 5:nginx -s reload 报 Access is denied
bash
nginx.exe -s reload
# OpenEvent("Global\ngx_reload_1234") failed (5: Access is denied)
nginx 以 LocalSystem 服务身份运行,而 SSH 登录的 Administrator 打不开它的全局事件对象;
甚至 nginx -t 也会报 logs/access.log: Access is denied。
解法:直接重启服务(1~2 秒中断)。
powershell
C:\ProgramData\chocolatey\lib\NSSM\tools\nssm.exe restart nginx
如果你用的是 sc 管理的服务,sc stop nginx && sc start nginx 同理。
4.2 文件怎么传上去?
scp 对 Windows 远程路径经常报:
dest open "C:/temp/xxx": No such file or directory
解法:base64 + certutil 分块传输。
powershell
# 本地:base64
$b64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes('本地证书.pem'))
# 大文件按 2000 字符分块,第一次 >,后续 >> 追加
& ssh server "echo $b64> C:\temp\p.b64"
# 服务器:解码
& ssh server "certutil -decode C:\temp\p.b64 C:\目标路径"
4.3 验证
bash
echo | openssl s_client -connect chat.xutongbao.top:443 \
-servername chat.xutongbao.top 2>/dev/null | \
openssl x509 -noout -subject -issuer -dates
curl -s -o /dev/null -w "http=%{http_code} ssl_verify=%{ssl_verify_result}\n" \
https://chat.xutongbao.top
期望:
issuer=C=US, O=Let's Encrypt, CN=YE1
notAfter=Jan 15 00:43:57 2027 GMT
http=200 ssl_verify=0
五、部署到七牛 CDN
★ 坑 6:七牛只接受 RSA 证书
先签的 ECDSA 证书上传,七牛返回:
json
{"code":400324,"error":"https证书解码失败"}
一开始我以为是格式问题,把 PEM / DER / 裸 base64 / 换行符换了个遍,全部同一句错误。
最后换成 RSA 2048 重新签一张,立刻通过------结论:七牛这个接口只吃 RSA。
5.1 上传证书:注意字段名和 ca
七牛的证书上传接口:
POST https://api.qiniu.com/sslcert
Content-Type: application/x-www-form-urlencoded
字段(踩坑重点):
name = 证书名称(唯一)
common_name = static.xutongbao.top
cert = 叶子证书 + 中间链(PEM)
ca = ★ 放【叶子证书】
pri = ★ 私钥(不是 private_key!)
我最初用 private_key,一路报 400327 解析https证书密钥失败;
改成 pri 后,错误变成 400328 证书与密钥不匹配------说明私钥终于被识别了,但比对失败。
再试把 ca 换成叶子证书,直接返回:
json
{"code":200,"error":"","certID":"6ad2d71eae9ad74a0980f789"}
这条接口的语义有点反直觉:
ca里放叶子证书,它拿去和私钥做匹配。
5.2 请求签名(QBox)
七牛管理 API 的签名规则:
Authorization: QBox <AccessKey>:<urlsafe_base64(HMAC-SHA1(SecretKey, path + "\n" + body))>
要点:
path含查询串;body必须和实际发送的内容逐字节一致;- 用
curl --data-binary @body.txt,不要用 PowerShell 的Invoke-WebRequest(会改 body)。
★ 坑 7:绑定接口不是 /httpscert
我把公开文档里的路径试了个遍:
POST/PUT/GET /domain/<域名>/httpscert → 404
/ httpsconf / https / ssl / cert / conf → 404
POST /domain/<域名> (带 certID) → 400561 未知的证书id
最后是在控制台操作一次,抓浏览器的 Network 才定位到真正接口:
PUT https://api.qiniu.com/domain/<域名>/httpsconf
Content-Type: application/json
{
"forceHttps": false,
"http2Enable": true,
"freeCert": false,
"tlsVersions": ["TLSv1.0", "TLSv1.1", "TLSv1.2", "TLSv1.3"],
"certId": "6ad2d71eae9ad74a0980f789"
}
三个细节全错才对不上:
| 项目 | 我一开始 | 正确 |
|---|---|---|
| 路径 | /httpscert |
/httpsconf |
| 方法 | POST | PUT |
| 字段 | certID |
certId(驼峰) |
小技巧:遇到厂商"文档和实际不一致",最快的方式就是打开控制台 → F12 Network → 操作一次 → 看请求。
注意:抓包里的 Cookie 是登录凭据,不要粘贴给别人、更不要发到聊天里。
5.3 验证绑定
bash
# 1) 平台侧
GET /domain/static.xutongbao.top
# operationType = modify_cert
# operatingState = success
# https.certId = <新证书 ID>
# 2) 线上实际
echo | openssl s_client -connect static.xutongbao.top:443 \
-servername static.xutongbao.top 2>/dev/null | \
openssl x509 -noout -subject -issuer -dates
期望:
issuer=C=US, O=Let's Encrypt, CN=YR2
notAfter=Jan 15 00:55:32 2027 GMT
注意:CDN 根路径返回 404 是正常的(源站 bucket 没有 index.html),
只看
ssl_verify=0即可,不要被 404 吓到。
六、验收清单(建议照抄)
| 检查项 | 命令/方法 | 期望 |
|---|---|---|
| 证书签发者 | `openssl s_client ... | openssl x509 -issuer` |
| 到期时间 | 同上 -dates |
3 个月后 |
| chat 站点 | curl -w "%{http_code} %{ssl_verify_result}" |
200 0 |
| static 站点 | 同上 | 404 0(根路径 404 正常) |
| 七牛绑定 | GET /domain/<域名> |
modify_cert / success |
| 旧证书备份 | 服务器目录 | *.bak-YYYYMMDD 存在 |
七、安全与收尾
- RAM 子账号最小权限,AK 用完即删/轮换;
- AK 文件不要提交到 git,不要出现在日志里;
- 控制台 Cookie(
PORTAL_SESSION等)等同密码,不要外发; - DNS 里临时的
_acme-challengeTXT 记录记得删; - 旧证书备份保留一份,出问题能 1 分钟回滚;
- 计划任务用完删除,避免误触发。
八、经验总结(7 个坑)
| # | 坑 | 现象 | 解法 |
|---|---|---|---|
| 1 | dns-01 值二次哈希 | Incorrect TXT record |
直接用 getChallengeKeyAuthorization() 返回值 |
| 2 | Resolver 传域名 | Invalid IP address |
先 resolve4 成 IP |
| 3 | 调 verifyChallenge() |
脚本挂起 | 跳过,直接 completeChallenge |
| 4 | 长任务被会话杀 | 日志停在中间 | 计划任务 + appendFileSync |
| 5 | nginx -s reload |
Access is denied |
nssm restart nginx |
| 6 | 七牛 ECDSA | 400324 证书解码失败 |
改用 RSA 2048 |
| 7 | 七牛绑定接口猜错 | 404 / 400561 |
PUT /httpsconf + certId |
九、最后
整套流程跑完,两张证书从"明天到期"变成"三个月后到期":
chat.xutongbao.top Let's Encrypt YE1 2026-10-17 → 2027-01-15
static.xutongbao.top Let's Encrypt YR2 2026-10-17 → 2027-01-15
如果你也在维护"自建 nginx + CDN"混合架构,希望这 7 个坑能帮你少走点弯路。
证书这东西,平时没人想起它,过期那天所有人都想起它。提前一周办,永远不亏。