CloudflareDNS和腾讯云CDN自动续期免费HTTPS证书

www.liuvv.com 的证书以前经常过期。表面看像是「域名在 Cloudflare,证书却没续上」,但真实结构并非如此:Cloudflare 只负责 DNS,访客真正访问的是腾讯云 CDN,浏览器看到的证书也安装在腾讯云 CDN 上。

本文记录一套适配该场景的免费自动化方案:GitHub Actions 定时运行 acme.sh,通过 Cloudflare DNS-01 验证申请 Let’s Encrypt 证书,再调用腾讯云 SSL/CDN API,把证书部署到 www.liuvv.com 的 CDN 加速域名。

1. 先分清两个链路

证书自动化最容易混乱的地方,是把「访问链路」和「续期链路」混在一起。先看访问链路。

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
flowchart LR
    A["浏览器 HTTPS 访问"] --> B["Cloudflare DNS 查询"]
    B --> C["返回 CNAME 记录"]
    C --> D["腾讯云 CDN 边缘节点"]
    D --> E["腾讯云 COS Website 源站"]
    D --> F["返回 HTTPS 证书和网页内容"]
    F --> A

    classDef dns fill:#DBEAFE,stroke:#2563EB,color:#1E3A5F;
    classDef cdn fill:#FEF3C7,stroke:#F59E0B,color:#78350F;
    classDef user fill:#DCFCE7,stroke:#10B981,color:#064E3B;

    class B,C dns;
    class D,E,F cdn;
    class A user;

具体域名是 www.liuvv.com,CNAME 指向 www.liuvv.com.cdn.dnsv1.com

这条链路里,Cloudflare 的角色是权威 DNS。它告诉浏览器 www.liuvv.com 应该去找腾讯云 CDN,但不承载该域名的流量,也不终止浏览器侧 TLS。

因此证书过期时,不要说「Cloudflare 证书过期」。对这个站点,更准确的说法是:

1
腾讯云 CDN 边缘证书过期了。

分清访问链路后,下一步要回答:为什么不用腾讯云控制台里现成的免费证书?

2. 为什么不用腾讯云免费证书控制台?

如果 DNS、CDN、证书都在腾讯云,控制台里的免费证书申请、自动续费、证书托管可能是最简单的路。

www.liuvv.com 的结构并非如此:

1
2
3
4
DNS 托管:Cloudflare
CDN 加速:腾讯云 CDN
源站内容:腾讯云 COS Website
访客证书:腾讯云 CDN 边缘证书

目标也不是「拿到一张新证书」这么简单,而是:

1
2
3
4
5
签发证书
-> 上传腾讯云 SSL
-> 部署到腾讯云 CDN
-> 保持 CDN HTTPS 服务开启
-> 从公网确认网站真的能打开

如果只完成第一步,网站仍可能继续使用旧证书,或者证书有效但 HTTPS 服务被关掉。腾讯云 CDN 在 HTTPS 服务关闭时会直接返回 514

因此这里选择 API 自动化:ACME 申请免费证书,Cloudflare 做 DNS-01 验证,腾讯云 API 完成上传和部署。下面先看完整续期链路。

3. 自动续期方案总览

完整续期链路如下:

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
flowchart TD
    A["GitHub Actions 定时运行"] --> B["acme.sh 开始申请证书"]
    B --> C["Let's Encrypt 要求验证域名控制权"]
    C --> D["acme.sh 调用 Cloudflare API"]
    D --> E["Cloudflare 添加 _acme-challenge TXT 记录"]
    E --> F["Let's Encrypt 查询 DNS TXT 记录"]
    F --> G["签发免费证书"]
    G --> H["GitHub Actions 读取证书和私钥"]
    H --> I["腾讯云 UploadCertificate 上传证书"]
    I --> J["腾讯云 DeployCertificateInstance 部署到 CDN"]
    J --> K["公网检查 TLS 证书"]
    K --> L["公网检查首页返回 2xx 或 3xx"]

    classDef dns fill:#DBEAFE,stroke:#2563EB,color:#1E3A5F;
    classDef cert fill:#DCFCE7,stroke:#10B981,color:#064E3B;
    classDef cdn fill:#FEF3C7,stroke:#F59E0B,color:#78350F;
    classDef verify fill:#FEE2E2,stroke:#EF4444,color:#7F1D1D;

    class D,E,F dns;
    class B,C,G,H cert;
    class I,J cdn;
    class K,L verify;

Cloudflare 出现在续期链路里,是因为 Let’s Encrypt 需要确认我控制 www.liuvv.com。DNS-01 验证会临时添加一条 TXT 记录:

1
_acme-challenge.www.liuvv.com TXT <challenge-token>

谁能添加这条记录,谁就能证明自己控制这个域名。liuvv.com 的 DNS 在 Cloudflare,acme.sh 必须拿 Cloudflare API Token 去添加和删除这条 TXT 记录。

链路清楚了,接下来分别准备 Cloudflare 和腾讯云的凭据。

4. 准备 Cloudflare Token

Cloudflare 只需要给 ACME 验证用,不需要让它接管访客流量。

在 Cloudflare Dashboard 里创建 API Token:

  1. 进入右上角头像菜单,打开 Profile
  2. 进入 API Tokens
  3. 点击 Create Token
  4. 使用 Edit zone DNS 模板,或自定义 token。
  5. 权限只给 Zone > DNS > Edit
  6. Zone 资源只选择 liuvv.com

最后得到两个值:

1
2
CLOUDFLARE_API_TOKEN=***
CLOUDFLARE_ZONE_ID=***

CLOUDFLARE_API_TOKEN 是密钥,不要写进仓库,也不要发到聊天记录里。CLOUDFLARE_ZONE_ID 不是私钥,也建议统一放到 GitHub Actions Secrets,避免到处散落。

Cloudflare 凭据就绪后,再创建腾讯云 CAM 子用户。

5. 准备腾讯云 CAM 子用户密钥

腾讯云这边需要调用两个能力:

1
2
UploadCertificate:上传证书到腾讯云 SSL
DeployCertificateInstance:把证书部署到腾讯云 CDN

推荐创建一个单独的 CAM 子用户,例如:

1
github-actions-cdn-cert

访问方式只开「编程访问」,不需要控制台登录。策略先按最小权限写:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"version": "2.0",
"statement": [
{
"effect": "allow",
"action": [
"ssl:UploadCertificate",
"ssl:DeployCertificateInstance"
],
"resource": "*"
}
]
}

然后给这个子用户创建 API 密钥,得到:

1
2
TENCENT_SECRET_ID=***
TENCENT_SECRET_KEY=***

这里有一个安全边界要说清楚:主账号密钥也能跑通,但不适合作为长期方案。主账号密钥权限太大,一旦泄露很难收敛影响。正式配置应该用 CAM 子用户,按实际报错逐步补权限,不要一开始就给全局管理员权限。

腾讯云的 SecretKey 创建后通常不能再次查看。创建时就要保存到密码管理器;如果丢了,只能新建一组密钥并更新 GitHub Secrets。

两边凭据都准备好之后,把它们写入 GitHub 仓库配置。

6. 配置 GitHub Secrets 和 Variables

仓库里需要 5 个 Secrets:

名称用途
ACME_EMAIL注册 Let’s Encrypt 账户
CLOUDFLARE_API_TOKEN添加和删除 DNS-01 TXT 记录
CLOUDFLARE_ZONE_ID限定 Cloudflare zone
TENCENT_SECRET_ID腾讯云 API SecretId
TENCENT_SECRET_KEY腾讯云 API SecretKey

用 GitHub CLI 可以这样写入:

1
2
3
4
5
gh secret set ACME_EMAIL --repo unix2dos/blog-liuvv
gh secret set CLOUDFLARE_API_TOKEN --repo unix2dos/blog-liuvv
gh secret set CLOUDFLARE_ZONE_ID --repo unix2dos/blog-liuvv
gh secret set TENCENT_SECRET_ID --repo unix2dos/blog-liuvv
gh secret set TENCENT_SECRET_KEY --repo unix2dos/blog-liuvv

命令会等待你输入值,输入内容不会回显。

还需要一个 GitHub Variable:

1
gh variable set TENCENT_CDN_BILLING --repo unix2dos/blog-liuvv --body on

这个值看起来像「计费」,但它实际影响腾讯云 CDN HTTPS 服务开关。对已经使用 HTTPS 的站点,要保持为 on。如果传 off,证书可能部署成功,但 HTTPS 请求会被腾讯云 CDN 拒绝,返回 514

Secrets 配好后,就可以写 GitHub Actions 工作流了。

7. GitHub Actions 工作流怎么写?

工作流放在:

1
.github/workflows/tencent-cdn-certificate.yml

核心结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name: Renew Tencent CDN certificate

on:
schedule:
- cron: "17 3 1 * *"
workflow_dispatch:
inputs:
dry_run:
description: "Validate the Tencent deployment payload without ACME or cloud API calls"
required: false
default: false
type: boolean

env:
CERT_DOMAIN: www.liuvv.com
ACME_HOME: ${{ github.workspace }}/.acme.sh
TENCENT_CDN_BILLING: ${{ vars.TENCENT_CDN_BILLING || 'on' }}

这里有两个入口:

  • 每月 1 日自动跑一次。
  • 手动触发,可以选择 dry_run

dry_run 只检查部署 payload,不申请证书,也不调用云 API。第一次配置时建议先跑它。

真正签发证书的核心步骤是:

1
2
3
4
5
6
7
8
9
10
- name: Issue certificate
env:
ACME_EMAIL: ${{ secrets.ACME_EMAIL }}
CF_Token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_Zone_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
run: |
set -euo pipefail
"$ACME_HOME/acme.sh" --home "$ACME_HOME" --set-default-ca --server letsencrypt
"$ACME_HOME/acme.sh" --home "$ACME_HOME" --register-account -m "$ACME_EMAIL" --server letsencrypt
"$ACME_HOME/acme.sh" --home "$ACME_HOME" --issue --server letsencrypt --dns dns_cf -d "$CERT_DOMAIN" --keylength 2048

CF_TokenCF_Zone_IDacme.sh 的 Cloudflare DNS 插件会读取的环境变量。--dns dns_cf 表示用 Cloudflare DNS API 完成 DNS-01 验证。

证书签发成功后,还要上传并部署到腾讯云 CDN。

8. 上传并部署到腾讯云 CDN

证书签发后,acme.sh 会在工作目录里留下两份关键文件:

1
2
.acme.sh/www.liuvv.com/fullchain.cer
.acme.sh/www.liuvv.com/www.liuvv.com.key

然后调用本仓库脚本:

1
2
3
4
5
6
7
8
9
10
11
- name: Deploy certificate to Tencent CDN
env:
TENCENT_SECRET_ID: ${{ secrets.TENCENT_SECRET_ID }}
TENCENT_SECRET_KEY: ${{ secrets.TENCENT_SECRET_KEY }}
run: |
set -euo pipefail
node tools/tencent-cdn-cert-deploy.js \
--cert "$ACME_HOME/${CERT_DOMAIN}/fullchain.cer" \
--key "$ACME_HOME/${CERT_DOMAIN}/${CERT_DOMAIN}.key" \
--domain "$CERT_DOMAIN" \
--billing-switch "$TENCENT_CDN_BILLING"

脚本做两件事。

第一步,调用腾讯云 SSL 的 UploadCertificate

1
2
3
4
5
{
"CertificateType": "SVR",
"CertificateUse": "CDN",
"Repeatable": false
}

第二步,调用 DeployCertificateInstance

1
2
3
4
5
6
7
{
"CertificateId": "新上传的证书 ID",
"ResourceType": "cdn",
"InstanceIdList": [
"www.liuvv.com|on"
]
}

重点是 www.liuvv.com|on。腾讯云文档里,CDN 资源的 InstanceIdList 形态是:

1
Domain|计费开关

所以这里不是随便拼了一个字符串。on 表示部署证书时保持 CDN HTTPS 服务开启。

部署完成后,还需要从公网侧验证结果——而且不能只看证书。

9. 验证不能只看证书

最开始我只验证了 TLS 证书:

1
2
3
4
docker-compose run --rm hexo-liuvv node tools/check-domain-certificate.js \
--domain www.liuvv.com \
--min-days 60 \
--issuer-contains "Let's Encrypt"

这个检查能证明:

  • 浏览器能拿到证书。
  • 证书域名覆盖 www.liuvv.com
  • 签发方是 Let’s Encrypt。
  • 剩余有效期不少于 60 天。

但它不能证明首页真的能打开。HTTPS 握手成功后,CDN 仍可能返回 5144035xx

所以工作流还要加一个公网首页检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
- name: Verify public CDN HTTP access
run: |
set -euo pipefail
for attempt in $(seq 1 20); do
status="$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 10 --max-time 20 "https://${CERT_DOMAIN}/")"
if [ "$status" -ge 200 ] && [ "$status" -lt 400 ]; then
echo "HTTPS access check passed with status $status"
exit 0
fi
echo "Attempt $attempt: expected 2xx/3xx from https://${CERT_DOMAIN}/, got $status"
sleep 30
done
exit 1

证书检查和首页检查要一起看:

1
2
TLS 证书有效:只能说明证书部署上去了
首页 2xx/3xx:才能说明用户访问链路恢复了

验证策略定下来之后,下面是一个真实踩过的坑。

10. 常见坑:证书有效,但返回 514

这次真实踩到的坑是:

1
2
3
证书已经是新的 Let's Encrypt
有效期也正常
但 https://www.liuvv.com/ 返回 HTTP 514

排查时先看三件事:

1
2
3
dig +short www.liuvv.com CNAME
curl -Iv https://www.liuvv.com/
openssl s_client -connect www.liuvv.com:443 -servername www.liuvv.com </dev/null

当时看到的结果是:

1
2
3
DNS: www.liuvv.com -> www.liuvv.com.cdn.dnsv1.com
TLS: Let's Encrypt 证书有效
HTTP: 腾讯云 CDN 返回 514

这说明 Cloudflare DNS 没坏,证书也不是主要问题。真正的问题在腾讯云 CDN 侧。

腾讯云 CDN 的 514 常见原因包括:

  • 没有启用 HTTPS 服务,却用 HTTPS 访问。
  • 命中 IP 黑白名单。
  • 命中 IP 访问限频。

本次原因是第一种:部署证书时把 TENCENT_CDN_BILLING 设成了 off,相当于关闭了 CDN HTTPS 服务。⚠️ 易错点:InstanceIdList 里的 off 会关掉 CDN HTTPS 服务,证书再新也会返回 514。

修复方式是:

1
gh variable set TENCENT_CDN_BILLING --repo unix2dos/blog-liuvv --body on

然后重新触发一次真实部署。恢复后,公网检查应该类似:

1
2
3
HTTP/2 200
server: tencent-cos
x-cache-lookup: Cache Refresh Hit

方案和排障都讲完了,完整代码和本地检查脚本见仓库文件。

11. 本地脚本和完整文件

正文只放关键片段,完整实现放在仓库文件里:

1
2
3
4
5
6
.github/workflows/tencent-cdn-certificate.yml
tools/tencent-cdn-cert-deploy.js
tools/check-domain-certificate.js
tools/check-tencent-cdn-cert-deploy.js
tools/check-domain-certificate-check.js
docs/tencent-cdn-certificate-renewal.md

本地契约检查:

1
2
docker-compose run --rm hexo-liuvv node tools/check-tencent-cdn-cert-deploy.js
docker-compose run --rm hexo-liuvv node tools/check-domain-certificate-check.js

注意本仓库的 Node/Hexo 命令都通过 docker-compose 执行,不直接使用宿主机 Node 环境。

本地脚本可用于改工作流前的回归验证。跑通之后,长期维护重点如下。

12. 后续维护

这套方案跑通后,长期维护重点有四个。

第一,定时任务不要太频繁。Let’s Encrypt 有频率限制,每月跑一次足够覆盖 90 天证书周期。

第二,密钥只放 GitHub Secrets,不进仓库。Cloudflare Token 和腾讯云 SecretKey 都不要写进 Markdown、YAML、脚本默认值或聊天记录。

第三,腾讯云主账号密钥只适合临时验证。验证通过后,应该迁移到 CAM 子用户,并禁用或删除主账号密钥。

第四,监控目标要看「网站可访问」,不要只看「证书未过期」。这次 514 已经证明,证书有效但网站仍然可能打不开。

各组件职责可以记成一张对照表:

1
2
3
4
5
6
Cloudflare:DNS 托管,给 DNS-01 验证用
Let's Encrypt:签发免费证书
GitHub Actions:定时执行自动化任务
腾讯云 SSL:接收上传证书
腾讯云 CDN:部署证书并承载 HTTPS 访问
公网验证:确认用户真的能打开网站

自动续期不是拿到证书就结束,而是要让腾讯云 CDN 用上新证书,并确认 https://www.liuvv.com/ 返回 2xx 或 3xx。

参考资料