用Let‘s Encrypt签发ssl证书

yourdomain.com SSL 证书自动化方案

为 yourdomain.com 域名签发 Let's Encrypt 通配符证书,通过腾讯云 SCF 云函数自动续签,本地 Tengine 部署,证书存 COS 备份并上传到 SSL 证书管理。


目录

  1. 架构概览
  2. ACME 协议与 Let's Encrypt
  3. 项目结构
  4. 核心流程详解
  5. DNS-01 挑战
  6. SCF 云函数自动部署
  7. 本地 Tengine 部署
  8. 证书上传到腾讯云 SSL 管理
  9. 运行日志示例
  10. 维护命令

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

评论

评论功能即将上线,敬请期待。