用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.comUploadCertificate 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

评论

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