yourdomain.com SSL 证书自动化方案
为
yourdomain.com域名签发 Let's Encrypt 通配符证书,通过腾讯云 SCF 云函数自动续签,本地 Tengine 部署,证书存 COS 备份并上传到 SSL 证书管理。
目录
- 架构概览
- ACME 协议与 Let's Encrypt
- 项目结构
- 核心流程详解
- DNS-01 挑战
- SCF 云函数自动部署
- 本地 Tengine 部署
- 证书上传到腾讯云 SSL 管理
- 运行日志示例
- 维护命令
1. 架构概览
┌─────────────────────────────────────────────────────────────┐
│ 用户请求 HTTPS │
│ │ │
│ ┌─────┴──────┐ │
│ │ Tengine │ 本地 Web 服务器 │
│ │ :80 → 301 │ │
│ │ :443 SSL │ │
│ └─────┬──────┘ │
│ │ 读取证书 │
│ ┌─────┴──────┐ /usr/local/tengine/certs/ │
│ │ COS 存储 │ fullchain.pem │
│ │ (广州) │ privkey.pem │
│ └─────┬──────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ┌──────┴──────┐ ┌────┴─────┐ ┌─────┴──────┐ │
│ │ SCF 云函数 │ │ SSL 证书 │ │ 本地备份 │ │
│ │ 自动续签 │ │ 管理平台 │ │ scf-ssl/ │ │
│ │ ap-shanghai │ │ CERTIFICATE_ID │ │ certs/ │ │
│ └──────┬──────┘ └──────────┘ └────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ Let's │ ACME v2 协议 │
│ │ Encrypt │ DNS-01 挑战 │
│ │ CA │◄──────────────────────────── │
│ └─────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ DNSPod │ TXT 记录 _acme-challenge │
│ │ 域名解析 │ 添加/删除 │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
2. ACME 协议与 Let's Encrypt
ACME(Automatic Certificate Management Environment)
ACME v2 是 IETF 标准协议(RFC 8555),用于自动化证书申请和管理。Let's Encrypt 是全球最大的 ACME CA。
核心概念:
| 概念 | 说明 |
|---|---|
| Account Key | 用于识别身份的 RSA 密钥对 |
| Order | 一次证书申请订单,包含一个或多个 identifier |
| Authorization | 每个 identifier 需要完成至少一项挑战 |
| Challenge | 证明域名控制权的方式 |
| CSR | 证书签名请求(Certificate Signing Request) |
挑战类型
| 类型 | 说明 | 适用场景 |
|---|---|---|
| HTTP-01 | 在 /.well-known/acme-challenge/ 放指定文件 |
有 Web 服务器,端口 80 可访问 |
| DNS-01 | 在 DNS TXT 记录 _acme-challenge.example.com 放指定值 |
通配符证书、无 HTTP 服务器 |
| TLS-ALPN-01 | 在 443 端口返回特定证书 | 精细控制 TLS 层 |
本方案选择 DNS-01,原因:
- 支持通配符证书
*.yourdomain.com- 不需要对外暴露 HTTP 服务
- 通过 DNSPod API 自动完成
3. 项目结构
scf-ssl/
├── src/
│ ├── index.js SCF 主入口 + 流程编排
│ ├── config.js 环境变量配置模块
│ ├── acme.js ACME 协议核心逻辑
│ ├── dnspod.js DNSPod API(TC3-HMAC-SHA256)
│ └── cos-store.js COS 对象存储读写
├── deploy.js SCF 一键部署脚本
├── certs/ 本地证书文件(gitignore)
├── package.json
├── .env.example
└── README.md
4. 核心流程详解
4.1 完整签发流程
SCF 触发 → 从 COS 读取 meta.json
│
├─ 到期 > 15 天
│ └─ 跳过,直接返回
│
└─ 需要续签(或首次)
│
├─ 1. 加载 ACME 账户密钥
│ ├─ COS 有 account-key.pem → 直接使用
│ └─ 首次运行 → 生成 RSA 4096 密钥 → 保存到 COS
│
├─ 2. 连接 Let's Encrypt
│ ├─ ENV=staging → acme-staging-v02.api.letsencrypt.org ✓ 无速率限制
│ └─ 生产环境 → acme-v02.api.letsencrypt.org ⚠️ 有速率限制
│
├─ 3. 注册账户(首次自动创建)
│ POST /acme/new-account
│ { termsOfServiceAgreed: true, contact: ["mailto:email"] }
│
├─ 4. 生成 CSR
│ 域名私钥 (RSA 2048)
│ ├─ CN = yourdomain.com
│ └─ SAN = [yourdomain.com, *.yourdomain.com]
│
├─ 5. 下单
│ POST /acme/new-order
│ identifiers: [
│ { type: "dns", value: "yourdomain.com" },
│ { type: "dns", value: "*.yourdomain.com" }
│ ]
│
├─ 6. 获取鉴权 + DNS-01 挑战
│ 每个 identifier 一个 authorization
│ 每个 authorization 包含 dns-01 challenge
│
├─ 7. DNS 挑战(以下在 auto 模式中自动完成)
│ ├─ challengeCreateFn → DNSPod 添加 TXT 记录
│ ├─ 等待 60 秒 DNS 传播
│ ├─ verifiyChallenge → 本地验证 DNS 已生效
│ ├─ completeChallenge → 通知 Let's Encrypt 验证
│ └─ challengeRemoveFn → DNSPod 删除 TXT 记录
│
├─ 8. Finalize 订单
│ POST /acme/order/{id}/finalize
│ { csr: "base64 CSR" }
│
├─ 9. 下载证书
│ GET /acme/cert/{id}
│ → 返回 PEM 格式证书链(3 张证书)
│
└─ 10. 保存到 COS
├─ fullchain.pem 证书链
├─ privkey.pem 域名私钥
└─ meta.json 到期元信息
4.2 SCF 主入口 (src/index.js)
export async function main_handler(event, context) {
// 1. 读取 COS meta.json,检查到期时间
const meta = await cosStore.readMeta(config.domain)
if (meta && daysUntilExpiry(meta.expiresAt) > 15) {
return { status: "skipped", daysRemaining } // 无需续签
}
// 2. 加载 ACME 账户密钥
const accountKey = await loadOrCreateAccountKey(config.domain)
// 3. 完整 ACME 签发流程
const result = await requestCertificate(config.domain, accountKey)
// 4. 保存证书到 COS
await cosStore.saveCertificates(config.domain, result)
await cosStore.writeMeta(config.domain, result)
}
4.3 使用 acme-client 的 auto 模式
本项目使用 acme-client npm 包的 auto 模式,它封装了整个 ACME 流程:
const certificate = await client.auto({
csr: csrBuf,
email: config.email,
termsOfServiceAgreed: true,
challengePriority: ["dns-01"],
// 挑战前:添加 DNS TXT 记录
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
const value = keyAuthorization // 这是 DNS-01 的 TXT 记录值
await dnspod.addTxtRecord(domain, "_acme-challenge", value)
await sleep(60000) // 等待 DNS 传播
},
// 挑战后:清理 DNS TXT 记录
challengeRemoveFn: async () => {
await dnspod.cleanChallengeRecords(domain, "_acme-challenge")
},
})
5. DNS-01 挑战
5.1 DNSPod API 调用(TC3-HMAC-SHA256 签名)
腾讯云 API 使用 TC3-HMAC-SHA256 签名算法。核心步骤:
function sign(secretId, secretKey, action, params) {
const algorithm = "TC3-HMAC-SHA256"
const timestamp = Math.floor(Date.now() / 1000)
const date = new Date(timestamp * 1000).toISOString().slice(0, 10)
const service = "dnspod"
const endpoint = "dnspod.tencentcloudapi.com"
// Step 1: 构建 CanonicalRequest
const payload = JSON.stringify(params)
const hashedPayload = sha256(payload)
const canonicalRequest = [
"POST",
"/",
"",
`content-type:application/json
host:${endpoint}
`,
"content-type;host",
hashedPayload,
].join("
")
// Step 2: 构建 StringToSign
const stringToSign = [
algorithm,
timestamp,
`${date}/${service}/tc3_request`,
sha256(canonicalRequest),
].join("
")
// Step 3: 派生签名密钥
const kDate = hmacSha256("TC3" + secretKey, date)
const kService = hmacSha256(kDate, service)
const kSigning = hmacSha256(kService, "tc3_request")
const signature = hmacSha256(kSigning, stringToSign)
// Step 4: 构建 Authorization
const authorization = `${algorithm} Credential=${secretId}/${date}/${service}/tc3_request, SignedHeaders=content-type;host, Signature=${signature}`
return { authorization, timestamp, payload }
}
5.2 通配符证书的特殊处理
通配符证书需要同时验证两个 identifier:
| Identifier | TXT 主机记录 | TXT 记录值 |
|---|---|---|
yourdomain.com |
_acme-challenge.yourdomain.com |
keyAuthorization A |
*.yourdomain.com |
_acme-challenge.yourdomain.com |
keyAuthorization B |
注意: 两个 identifier 使用相同的主机记录但不同的值!
在
challengeCreateFn中不能先清理再添加,否则会导致两条记录互相覆盖。 正确的做法是直接追加 TXT 记录(DNSPod 支持相同主机记录的多条 TXT), 在challengeRemoveFn中统一清理。
// ✅ 正确做法
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
// 不清理,直接添加(两条不同的 TXT 记录共存)
await dnspod.addTxtRecord(domain, "_acme-challenge", keyAuthorization)
await sleep(60000)
}
// ❌ 错误做法(会导致通配符记录被覆盖)
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
await dnspod.cleanChallengeRecords(domain, "_acme-challenge") // 不要!
await dnspod.addTxtRecord(domain, "_acme-challenge", keyAuthorization)
}
6. SCF 云函数自动部署
6.1 部署脚本 (deploy.js)
async function main() {
// 1. 打包代码(ZipArchive)
const archive = new ZipArchive({ zlib: { level: 9 } })
archive.file("src/index.js", { name: "index.js" })
archive.file("src/config.js", { name: "config.js" })
archive.file("src/acme.js", { name: "acme.js" })
archive.file("src/dnspod.js", { name: "dnspod.js" })
archive.file("src/cos-store.js", { name: "cos-store.js" })
archive.directory("node_modules/", "node_modules/")
// 注意: SCF 要求入口文件在 zip 根目录
// 2. 上传到 COS
// 3. 调用 CreateFunction API
await callAPI("scf", "CreateFunction", {
FunctionName: "ssl-auto-renew",
Runtime: "Nodejs18.15",
Handler: "index.main_handler",
Code: { CosBucketName, CosBucketRegion, CosObjectName },
Timeout: 180,
MemorySize: 128,
Environment: { Variables: [...] },
})
// 4. 创建定时触发器
await callAPI("scf", "CreateTrigger", {
Type: "timer",
TriggerDesc: "0 3 1,16 * *", // 每月 1日、16日 03:00
Enable: "OPEN",
})
}
6.2 部署命令
SECRET_ID=xxx SECRET_KEY=xxx COS_BUCKET=xxx node deploy.js
6.3 SCF 函数配置
| 配置项 | 值 |
|---|---|
| 函数名 | ssl-auto-renew |
| 运行时 | Nodejs 18.15 |
| 地域 | 上海 (ap-shanghai) |
| 入口 | index.main_handler |
| 超时 | 180 秒 |
| 内存 | 128 MB |
| 触发器 | timer, cron=0 3 1,16 * * |
6.4 环境变量
| 变量 | 说明 |
|---|---|
SECRET_ID |
腾讯云 API 密钥 ID(需 DNSPod + COS 权限) |
SECRET_KEY |
腾讯云 API 密钥 Key |
COS_BUCKET |
COS 存储桶名称 |
COS_REGION |
COS 地域,默认 ap-guangzhou |
DOMAIN |
证书域名,默认 yourdomain.com |
EMAIL |
ACME 注册邮箱 |
7. 本地 Tengine 部署
7.1 编译安装 Tengine
# 安装编译依赖
sudo apt-get install -y libpcre3-dev libssl-dev zlib1g-dev build-essential
# 下载 Tengine 3.1.0
wget https://tengine.taobao.org/download/tengine-3.1.0.tar.gz
tar xzf tengine-3.1.0.tar.gz
cd tengine-3.1.0
# 编译配置
./configure \
--prefix=/usr/local/tengine \
--sbin-path=/usr/local/tengine/sbin/nginx \
--conf-path=/usr/local/tengine/conf/nginx.conf \
--with-http_ssl_module \
--with-http_v2_module \
--with-stream \
--with-stream_ssl_module
# 编译安装
make -j$(nproc)
sudo make install
7.2 Nginx 配置
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://yourdomain.com$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /usr/local/tengine/certs/fullchain.pem;
ssl_certificate_key /usr/local/tengine/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
root /var/www/yourdomain.com;
index index.html;
}
7.3 加载证书
sudo cp certs/fullchain.pem /usr/local/tengine/certs/
sudo cp certs/privkey.pem /usr/local/tengine/certs/
sudo /usr/local/tengine/sbin/nginx -t # 验证配置
sudo /usr/local/tengine/sbin/nginx # 启动
sudo /usr/local/tengine/sbin/nginx -s reload # 重载
8. 证书上传到腾讯云 SSL 管理
通过 ssl.tencentcloudapi.com 的 UploadCertificate API:
const result = await callAPI("ssl", "UploadCertificate", {
CertificatePublicKey: fullchainPem,
CertificatePrivateKey: privkeyPem,
Alias: "yourdomain.com (*.yourdomain.com)",
ProjectId: 0,
})
// 返回 CertificateId: "CERTIFICATE_ID"
上传后可在腾讯云控制台 → SSL 证书管理 中查看,可用于 CDN、CLB、API 网关等服务。
9. 运行日志示例
首次签发
[ssl-auto-renew] 开始执行
[ssl-auto-renew] 环境模式: production(正式)
[ssl-auto-renew] 目标域名: yourdomain.com
[ssl-auto-renew] 检查现存证书...
[ssl-auto-renew] 未找到现存证书,首次签发
[ssl-auto-renew] 准备 ACME 账户密钥...
[ACME] 生成新账户密钥 (RSA 4096)
[COS] ACME 账户密钥已保存
[ssl-auto-renew] 开始 ACME 签发流程...
[ACME] 目标 CA: PRODUCTION (正式)
[ACME] 为 yourdomain.com 签发证书
[ACME] 生成域名密钥对和 CSR(含通配符 *.yourdomain.com)
[ACME] 启动 auto 模式...
[ACME] DNS-01 挑战: yourdomain.com → SOMVYBzEItFCNctq1PYV...
[DNSPod] 添加 TXT 记录: _acme-challenge.yourdomain.com
[ACME] 等待 DNS 传播 (60s)...
[DNSPod] 挑战记录已清理
[ACME] ✅ 证书签发成功!
[ACME] 签发日期: 2026-07-14T02:26:31Z
[ACME] 到期日期: 2026-10-12T02:26:31Z
[COS] 证书已保存: fullchain.pem + privkey.pem
[ssl-auto-renew] ✅ 签发成功! 耗时: 220.8s
跳过续签
[ssl-auto-renew] 开始执行
[ssl-auto-renew] 检查现存证书...
[ssl-auto-renew] 现存证书到期日: 2026-10-12, 剩余 89 天
[ssl-auto-renew] 证书仍有效 (89 天 > 15 天),无需续签,跳过
证书验证
# 检查 SAN(Subject Alternative Name)
openssl x509 -in fullchain.pem -noout -ext subjectAltName
# → DNS:*.yourdomain.com, DNS:yourdomain.com
# 检查有效期
openssl x509 -in fullchain.pem -noout -dates
# → notBefore=Jul 14 01:27:59 2026 GMT
# → notAfter=Oct 12 01:27:58 2026 GMT
# 检查 TLS 握手
openssl s_client -connect localhost:443 -servername yourdomain.com
10. 维护命令
证书相关
# 从 COS 下载证书
node -e "
import COS from 'cos-nodejs-sdk-v5';
const cos = new COS({ SecretId, SecretKey });
cos.getObject({ Bucket, Key: 'ssl/yourdomain.com/fullchain.pem' }, ...);
"
# 更新 Tengine 证书
sudo cp fullchain.pem /usr/local/tengine/certs/
sudo cp privkey.pem /usr/local/tengine/certs/
sudo /usr/local/tengine/sbin/nginx -s reload
# 上传到腾讯云 SSL 管理
node deploy-ssl-manager.js
SCF 相关
# 部署
SECRET_ID=xxx SECRET_KEY=xxx COS_BUCKET=xxx node deploy.js
# 本地测试(staging)
cd scf-ssl && ENV=staging node src/index.js
# 本地测试(production)
cd scf-ssl && node src/index.js
证书到期管理
| 触发条件 | 行为 |
|---|---|
| SCF 定时触发 | 检查 COS 中 meta.json 的 expiresAt |
| 剩余 > 15 天 | 跳过,等待下次触发器 |
| 剩余 ≤ 15 天 | 走完整 ACME 流程重新签发 |
| 首次(无 meta) | 走完整 ACME 流程签发 |
附录 A:使用的服务
| 服务 | 用途 |
|---|---|
| Let's Encrypt | ACME CA,签发证书 |
| DNSPod | 域名解析 DNS-01 挑战 |
| COS(腾讯云对象存储) | 证书持久化存储、SCF 代码存储 |
| SCF(腾讯云云函数) | 自动续签执行环境 |
| SSL 证书管理(腾讯云) | 证书统一管理 |
| Tengine | 本地 Web 服务器 |
附录 B:依赖的 npm 包
| 包 | 版本 | 用途 |
|---|---|---|
acme-client |
^5.4.0 | ACME v2 协议客户端 |
cos-nodejs-sdk-v5 |
^2.12.6 | 腾讯云 COS SDK |
附录 C:关键文件路径
| 文件 | 路径 |
|---|---|
| 证书链 | /usr/local/tengine/certs/fullchain.pem |
| 私钥 | /usr/local/tengine/certs/privkey.pem |
| COS 证书 | cos://your-cos-bucket/ssl/yourdomain.com/ |
| 本地源码 | ~/Projects/marriott/scf-ssl/ |
| Tengine 配置 | /usr/local/tengine/conf/nginx.conf |
| Tengine 日志 | /var/log/tengine/error.log |
评论
评论功能即将上线,敬请期待。