免费ssl证书全流程AI配置(含七牛云cdn)

一次真实的两站证书续期: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,再绑定域名

需求也很明确:

  1. 不用自动续期(保持手动可控);
  2. 但要可复现------半年后再来一次,照着做就能完成;
  3. 尽量脚本化,少点控制台。

于是有了这次"从零到上线"的完整折腾,也踩了 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 存在

七、安全与收尾

  1. RAM 子账号最小权限,AK 用完即删/轮换;
  2. AK 文件不要提交到 git,不要出现在日志里;
  3. 控制台 Cookie(PORTAL_SESSION 等)等同密码,不要外发;
  4. DNS 里临时的 _acme-challenge TXT 记录记得删;
  5. 旧证书备份保留一份,出问题能 1 分钟回滚;
  6. 计划任务用完删除,避免误触发。

八、经验总结(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 个坑能帮你少走点弯路。

证书这东西,平时没人想起它,过期那天所有人都想起它。提前一周办,永远不亏。

相关推荐
lisw051 小时前
制造业信息化:现代工业的发展趋势!
大数据·网络·人工智能
陈让然2 小时前
WSL 桥接设置静态IP(快速)
linux·网络·ubuntu
持敬chijing2 小时前
白盒测试与代码审计-js登录逆向实战
javascript·网络·安全·web安全·网络安全·网络攻击模型
裕晟资质规划3 小时前
三类涉密资质延续时限差异分析:军工60日与集成印制3个月的边界
java·服务器·前端·网络·安全
刘胡子大叔3 小时前
备份包只有 1.5K?
linux·服务器·网络
幻智星科技4 小时前
一个开源的纯FPGA网口采集卡
网络协议·fpga开发·udp
姜鱼问生5 小时前
每次开机手动检查 6 样东西?我写了个 2 秒自检脚本
运维·网络·nginx·docker·容器
钱工的门窗技术笔记5 小时前
智能门窗的边界与前提:为什么“智能”不能替代本体性能
网络·物联网·智能家居
是真的小外套6 小时前
2021年软考中级信息安全工程师:选择题+案例分析真题全解析(含答案)
网络