跳转至

全链路无人值守自动化 SSL

起因

腾讯云的免费 SSL 证书有效期只有 3 个月,且不支持自动续签,不是很方便(其他云厂商也类似)

解决方案

  • A:每三个月手动更新一次(免费 + 手动续签 + 手动上传)
  • B:使用 Let's Encrypt + acme.sh / Certbot 脚本(免费 + 自动续签 + 自动上传)
  • C:购买付费证书(付费 + 自动续签 + 无需上传)

选 B(土豪随意)

结合 acme.sh1 + GitHub Actions,撸了一套 全链路无人值守自动化 SSL 脚本,具备以下特色:

功能模块 特色说明
高可靠设计 ✓ 不依赖 Actions Cache 保存证书或私钥状态,适合临时 Runner
全自动运维 ✓ 无需人工干预,实现 SSL 证书生命周期全自动管理
自动签发 ✓ 自动调用 Let’s Encrypt 申请证书
智能续期 ✓ 检查线上证书到期时间,仅在临近过期时签发,避免无效请求
云端集成 ✓ 自动上传证书至腾讯云 SSL 证书管理
CDN 热更新 ✓ 自动更新 CDN HTTPS 配置并立即生效

脚本流程指令

脚本下载

acme-auto.yml
name: Auto SSL Renew & Deploy (Tencent Cloud)

# The workflow does not read the repository or use Actions cache/API state.
permissions: {}

on:
  workflow_dispatch:
    inputs:
      force_issue:
        description: 'Issue immediately; use only for first deployment or recovery.'
        required: false
        default: false
        type: boolean
  schedule:
    # Check weekly. A certificate is issued only when it expires
    # within 21 days; ordinary checks do not call Let's Encrypt.
    - cron: '17 3 * * 1'

# Prevent concurrent: manual trigger + cron
concurrency:
  group: ssl-renew-${{ github.repository }}
  cancel-in-progress: false

jobs:
  issue_cert:
    runs-on: ubuntu-latest

    steps:
      # -------------------------
      # Check the certificate currently served by the CDN
      # -------------------------
      - name: Check deployed certificate expiry
        id: cert_check
        env:
          DOMAIN: ${{ secrets.DOMAIN }}
        run: |
          set -euo pipefail

          # A manual forced run supports the first deployment, when no public
          # certificate has been configured on the CDN yet.
          if [ "${{ inputs.force_issue }}" = "true" ]; then
            echo "needs_issue=true" >> "$GITHUB_OUTPUT"
            echo "⚠️ Manual forced issuance requested."
            exit 0
          fi

          CERT_FILE="$(mktemp)"
          trap 'rm -f "$CERT_FILE"' EXIT

          # The leaf certificate is the first certificate returned by the TLS endpoint.
          if ! timeout 30 openssl s_client \
            -connect "${DOMAIN}:443" \
            -servername "$DOMAIN" \
            -showcerts </dev/null 2>/dev/null \
            | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' > "$CERT_FILE"; then
            echo "Unable to retrieve the certificate served by ${DOMAIN}." >&2
            exit 1
          fi

          if ! openssl x509 -in "$CERT_FILE" -noout -enddate; then
            echo "The TLS endpoint did not return a valid leaf certificate." >&2
            exit 1
          fi

          # A valid default certificate for a different hostname must not
          # suppress renewal for this workflow's two CDN domains.
          for HOSTNAME in "$DOMAIN" "www.$DOMAIN"; do
            if ! openssl x509 -in "$CERT_FILE" -noout -checkhost "$HOSTNAME"; then
              echo "The deployed certificate does not cover ${HOSTNAME}." >&2
              exit 1
            fi
          done

          # Keep a 21-day recovery window for DNS or cloud-side deployment failures.
          if openssl x509 -in "$CERT_FILE" -noout -checkend $((21 * 24 * 60 * 60)); then
            echo "needs_issue=false" >> "$GITHUB_OUTPUT"
            echo "🔹 Current certificate is valid for more than 21 days."
          else
            echo "needs_issue=true" >> "$GITHUB_OUTPUT"
            echo "🔔 Current certificate expires within 21 days."
          fi

      # -------------------------
      # Install acme.sh only when a new certificate is needed
      # -------------------------
      - name: Install acme.sh
        if: steps.cert_check.outputs.needs_issue == 'true'
        env:
          ACME_EMAIL: ${{ secrets.ACME_EMAIL }}
        run: |
          export PATH="$HOME/.acme.sh:$PATH"

          echo "⬇️ Installing acme.sh ..."
          curl -fsSL https://get.acme.sh | sh -s email="$ACME_EMAIL"

      # -------------------------
      # Issue a certificate only when the deployed certificate is near expiry
      # -------------------------
      - name: Issue certificate
        id: renew
        if: steps.cert_check.outputs.needs_issue == 'true'
        env:
          DOMAIN: ${{ secrets.DOMAIN }}
          DP_Id: ${{ secrets.DP_ID }}
          DP_Key: ${{ secrets.DP_KEY }}
        run: |
          set -euo pipefail
          export PATH="$HOME/.acme.sh:$PATH"

          echo "🎬 Issuing a new certificate..."
          ~/.acme.sh/acme.sh \
            --issue \
            -d "$DOMAIN" \
            -d "www.$DOMAIN" \
            --dns dns_dp \
            --ecc \
            --server letsencrypt \
            --dnssleep 120

          echo "renewed=true" >> "$GITHUB_OUTPUT"
          echo "🔔 Certificate issued"

      # -------------------------
      # Prepare certificate
      # -------------------------
      - name: Prepare cert files
        if: steps.renew.outputs.renewed == 'true'
        env:
          DOMAIN: ${{ secrets.DOMAIN }}
        run: |
          mkdir -p certs
          cp ~/.acme.sh/${DOMAIN}_ecc/${DOMAIN}.key certs/
          cp ~/.acme.sh/${DOMAIN}_ecc/fullchain.cer certs/

      # -------------------------
      # Deploy to Tencent Cloud
      # -------------------------
      - name: Upload cert and update CDN
        if: steps.renew.outputs.renewed == 'true'
        env:
          QCLOUD_SECRET_ID: ${{ secrets.QCLOUD_SECRET_ID }}
          QCLOUD_SECRET_KEY: ${{ secrets.QCLOUD_SECRET_KEY }}
          DOMAIN: ${{ secrets.DOMAIN }}
        run: |
          pip install --prefer-binary --disable-pip-version-check tencentcloud-sdk-python==3.1.50

          python3 <<'EOF'
          import os, json
          from tencentcloud.common import credential
          from tencentcloud.common.profile.http_profile import HttpProfile
          from tencentcloud.common.profile.client_profile import ClientProfile
          from tencentcloud.ssl.v20191205 import ssl_client, models
          from tencentcloud.cdn.v20180606 import cdn_client, models as cdn_models

          # ---------- Upload cert ----------
          cred = credential.Credential(
              os.getenv("QCLOUD_SECRET_ID"),
              os.getenv("QCLOUD_SECRET_KEY")
          )

          ssl = ssl_client.SslClient(cred, "ap-guangzhou")

          domain = os.getenv("DOMAIN")

          with open("certs/fullchain.cer") as f:
              pub = f.read()

          with open(f"certs/{domain}.key") as f:
              key = f.read()

          upload_req = models.UploadCertificateRequest()
          upload_req.from_json_string(json.dumps({
              "CertificatePublicKey": pub,
              "CertificatePrivateKey": key,
              "Alias": f"AutoSSL-{domain}"
          }))

          upload_resp = ssl.UploadCertificate(upload_req)
          cert_id = json.loads(upload_resp.to_json_string())["CertificateId"]

          print("✅ Uploaded new cert: ", cert_id)


          # ---------- Update CDN ----------
          domains = [domain, f"www.{domain}"]

          cdn = cdn_client.CdnClient(cred, "ap-guangzhou")
          for d in domains:
              try:
                  print("🔧 Updating CDN for domain: ", d)
                  req = cdn_models.UpdateDomainConfigRequest()
                  req.from_json_string(json.dumps({
                      "Domain": d,
                      "Https": {
                          "Switch": "on",
                          "CertInfo": {"CertId": cert_id}
                      }
                  }))

                  cdn.UpdateDomainConfig(req)
                  print("✅ CDN updated: ", d)
              except Exception as e:
                  print("❌ CDN update failed: ", d, e)
                  raise
          EOF

      # -------------------------
      # Result
      # -------------------------
      - name: Result
        if: always()
        run: |
          if [ "${{ steps.renew.outputs.renewed }}" = "true" ]; then
            echo "✅ SSL renewed and deployed."
          else
            echo "🔹 No renewal needed."
          fi

执行流程

脚本执行流程如下:

trigger workflow
      │
      ▼
read certificate served by DOMAIN:443
      │
      ▼
is it valid for more than 21 days?
      │
      ├── yes ── ▶︎ exit (no ACME request)
      │
      └── no
          │
          ▼
      install acme.sh
          │
          ▼
      issue a new certificate via DNSPod
          │
          ▼
      upload certificate to Tencent Cloud
          │
          ▼
      update CDN

签发指令

命令 作用 是否需要验证参数 使用场景
issue 申请新证书 需要 线上证书剩余有效期不足 21 天时

工作流每周检查一次 CDN 对外提供的 DOMAIN:443 证书。检查本身不会访问 Let’s Encrypt;只有证书剩余不足 21 天才会执行 acme.sh --issue,因此正常情况下约每两到三个月才签发一次。

该方案不缓存 ~/.acme.sh。GitHub Actions 的缓存适合依赖加速,不适合作为会更新的证书状态存储,也避免在缓存中长期保存账户密钥、域名私钥或 DNS API 凭据。

配置使用

脚本以腾讯云为样例,如需使用其他云厂商,只需把脚本中的步骤 Deploy to Tencent Cloud 代码替换为对应的版本

DNSPod Token 配置

腾讯云 → DNSPod 「账号中心 → API密钥」,选择 DNSPod Token → 创建密钥,记下 ID 和 Token

ID:668000
Token:1md3646b5c4d7ba5969c48e521629ird

API 密钥配置

腾讯云 → DNSPod 「账号中心 → API密钥」,选择 腾讯云 API 密钥 → 新建密钥,记下 SecretId 和 SecretKey

SecretId:A******441jEXeZJp9LL8kA22Bl9k8******
SecretKey:B*****FG4e1B7u52LSbcTHzdDl7******

自动化配置

可使用 GitHub Actions 工作流来触发定时任务完成证书的自动签发、上传与更新(省去云服务器)

创建 GitHub 仓库

在 GitHub 创建一个仓库

设置仓库 secrets

仓库 → Settings → Secrets and variables → Actions → New repository secret,分别添加:

Secret 名称 内容 来源
DP_Id DNSPod ID DNSPod 控制台
DP_Key DNSPod Token DNSPod 控制台
QCLOUD_SECRET_ID 腾讯云 SecretId 腾讯云访问管理
QCLOUD_SECRET_KEY 腾讯云 SecretKey 腾讯云访问管理
DOMAIN 你的备案域名 备案域名
ACME_EMAIL 随意一个真实邮箱 手动填写

创建工作流

仓库首页 → Add file → Create a new file: .github/workflows/acme-auto.yml,内容见 脚本下载。

DOMAIN 与 www.DOMAIN 必须已经指向 CDN,且外网可访问到一张同时覆盖这两个域名的证书。工作流会验证证书有效期和域名覆盖范围;检查失败时会停止而不是盲目签发,避免网络或 CDN 配置故障导致重复申请。

运行与测试

已有 HTTPS 证书时:仓库 → Actions → 选中左侧 Auto SSL Renew & Deploy → 点击右侧 Run workflow。正常结果应为“当前证书剩余超过 21 天”,不签发也不上传。

首次部署、尚未配置 CDN 证书,或需要人工故障恢复时:点击 Run workflow 后勾选 force_issue,再运行。该选项会跳过到期检查并立即签发,日常请不要勾选。

成功签发后,查看腾讯云:

  • SSL 证书 → 我的证书,查看是否存在刚上传的 AutoSSL-{domain} 证书(记下ID)
  • 内容分发网络 CDN → 证书配置,查看域名对应的证书是否已更新为最新证书(看证书ID)

  1. acme.sh 是一个用于 SSL / TLS 证书自动化的纯 Unix shell 脚本,实现了 acme 协议,可以从 ZeroSSL,Let's Encrypt 等 CA 生成免费的证书 ↩