Menci's Blog
念念不忘,必有回响
使用 GitHub Actions 自动申请与部署 ACME SSL 证书
  1. 1. 申请证书
    1. 1.1. 准备
    2. 1.2. ACME Action
    3. 1.3. 上传证书
  2. 2. 部署证书
    1. 2.1. Azure Key Vault
    2. 2.2. Azure App Service
    3. 2.3. 阿里云
    4. 2.4. 又拍云
    5. 2.5. 服务器
  3. 3. 完整例子
  4. 4. 关于 GTS 证书

对于将 Web 服务部署在多个服务器与云服务的人来说,如何方便地申请并管理 SSL 证书,是一个值得思考的问题。我使用 GitHub Actions 来自动申请/续期 ACME SSL 证书,部署到各个云平台上,并在自己管理的服务器上定期拉取证书。

首先创建一个私有 GitHub 仓库用于存放证书配置和各类凭据,以及存放申请到的证书。

申请证书

准备

首先请在本地(或自己的服务器上)成功使用 acme.sh 的 DNS-01 验证方式成功申请一次证书。这个过程包括:

  1. 向 CA 注册 ACME 账户(如果使用 Let's Encrypt 则会自动进行,如果使用 ZeroSSL 或者 GTS 则需要手动注册)。
  2. 通过环境变量指定 DNS 提供商的凭据,用于添加/删除 ACME DNS-01 认证所需的 TXT 记录。
  3. 确认证书申请可以成功,为后续调试排除可能的问题。

第一次申请证书后,CA 的 ACME 账户凭据将被存储到 ~/.acme.sh/ca 中,DNS 提供商的凭据将被存储到 ~/.acme.sh/account.conf 中。将它们打包并 BASE64 以备在 GitHub Actions 上使用:

cd ~/.acme.sh
tar cz ca account.conf | base64 -w0

将输出内容添加到 GitHub 仓库的 Secrets 中。

ACME Action

使用 Action Menci/acme 可以通过 acme.sh 申请 SSL 证书(使用 DNS-01 验证)。需要以下参数:

  • account-tar:用于指定 ACME 客户端的凭据。使用刚刚生成好的 Secret 即可。
  • domains-file:指定一个文件,其中包含需要为其申请证书的域名列表,用空白字符隔开。
    • 亦可直接使用 domain 参数来指定域名列表,但不推荐直接将域名列表硬编码在 Workflow 文件中。
    • 默认将包含它们对应的 wildcard 域名(即 example.com 会包含 *.example.com),如果不希望自动添加,则可以设置 append-wildcardfalse
  • arguments-file:指定一个文件,其中包含向 acme.sh 传递的参数列表。主要用于指定 --dns--server 参数。如 --dns dns_cf --server letsencrypt
    • 亦可直接使用 arguments 参数来指定参数列表。
    • 其它常用参数如 --valid-to--challenge-alias 也可以在此设定。
  • output-fullchain:输出 PEM 格式的全链证书文件的路径(如果目录不存在将自动创建,下同)。
  • output-key:输出 PEM 格式的证书私钥文件的路径。
  • output-pfx:输出 PFX 格式的全链证书文件的路径(如果不需要可以留空,上同)。
    • 使用 output-pfx-password 指定 PFX 文件的密码,随意指定即可。

另外,建议通过 version 参数指定 acme.sh 的版本(版本号或者 commit)来保持稳定性。

上传证书

因为我们将在不同的 Job 中执行接下来的证书部署操作,我们需要在申请证书的 Job 的最后将证书 push 到仓库中(或者上传到 Artifacts 中)。

首先创建一个空的分支:

mkdir /tmp/empty-repo && cd /tmp/empty-repo && git init   # 创建一个临时 repo
git commit --allow-empty -m "Initial commit"              # 创建一个空的 commit
git push [email protected]:<username>/<repo> HEAD:certs-main # push 到新的分支上

在申请证书之前 checkout 这个分支到一个子目录,将申请到的证书放到这个目录下,并 commit-push 即可:

- name: Push to GitHub
  run: |
    git config --global user.name $(git show -s --format='%an' HEAD)
    git config --global user.email $(git show -s --format='%ae' HEAD)
    cd "$CERTS_OUTPUT_DIRECTORY"
    git add "$FILE_KEY" "$FILE_FULLCHAIN" "$FILE_PFX"
    git commit -m "[Actions/${{ github.workflow }}] Upload certificates on $(date '+%Y-%m-%d %H:%M:%S')"
    git push
  env:
    TZ: Asia/Shanghai

部署证书

在申请证书的 Job 执行完成后,我们执行一系列 Job 来将证书部署到各个云服务。

Azure Key Vault

Key Vault 是 Azure 提供的密钥存储服务,可以用于存储 SSL 证书。并可以用于在 Front Door 中使用。

使用 Azure CLI(azure/CLI)将证书部署到 Key Vault:

- name: Login to Azure
  uses: azure/[email protected]
  with:
    creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Upload certificate
  uses: azure/[email protected]
  with:
    inlineScript: |
      az keyvault certificate import --subscription "$AZURE_SUBSCRIPTION" \
                                     --vault-name "$AZURE_KEY_VAULT_NAME" \
                                     --file "$CERTS_OUTPUT_DIRECTORY/$FILE_PFX" \
                                     --name "$AZURE_KEY_CERTIFICATE_NAME" \
                                     --password "$PFX_PASSWORD"
  env:
    AZURE_SUBSCRIPTION: ${{ matrix.subscription }}
    AZURE_KEY_VAULT_NAME: ${{ matrix.vault-name }}
    AZURE_KEY_CERTIFICATE_NAME: ${{ matrix.certificate-name }}

在 Front Door 中指定来自 Key Vault 的证书后,证书将自动更新。

所使用的 Service Principal 需要拥有该 Key Vault 的 Certificate → Import 权限。使用以下命令可以创建一个 Service Principal:

az ad sp create-for-rbac --name SSLCertificateUploader
{
  "appId": "019d7f61-a969-4daa-b53f-f2341f6f4705",
  "displayName": "SSLCertificateUploader",
  "password": "vCGS5BQMm.BI~6BEjjCa-j6tZ2fCxJsENA",
  "tenant": "72f988bf-86f1-41af-91ab-2d7cd011db47"
}

输出的内容即为该 Service Principal 的凭据(建议删除换行以免 {} 被认为是 Secret,导致在 Actions 的输出中被遮盖)。在 Azure Portal 中为其赋予权限即可。

Azure App Service

App ServiceFunction App 同理)是 Azure 提供的 Web 应用服务。App Service 绑定的域名需要手动上传 SSL 证书(也可从 Key Vault 中导入,但导入是一次性的,不会像 Front Door 一样自动更新)。

使用 Menci/deploy-certificate-to-azure-web-app 将证书部署到 App Service:

- name: Deploy certificate
  uses: Menci/[email protected]
  with:
    azcliversion: 2.28.0
    creds: ${{ secrets.AZURE_CREDENTIALS }}
    subscription: ${{ matrix.subscription }}
    resource-group: ${{ matrix.resource-group }}
    webapp-name: ${{ matrix.webapp-name }}
    certificate-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_PFX }}
    certificate-password: ${{ env.PFX_PASSWORD }}
    delete-old-certificates: true

该 Action 会将新的证书上传到 App Service 所在的 Resource Group 中,并为该 App Service 所绑定的所有匹配的域名应用该证书。当 delete-old-certificates 参数为 true 时,将自动删除该 App Service 之前使用的所有证书(包含非本 Action 上传的证书)。

所使用的 Service Principal 需要拥有 Resource Group 级别(而非 Resource 级别,因为同一个 Resource Group 的 SSL 证书是共享的)的 Website Contributor 权限。在 Azure Portal 中为其赋予权限即可。

阿里云

阿里云的 SSL 证书服务支持上传自定义证书,该证书可以用于阿里云 CDN。阿里云暂未提供将证书部署至 OSS 的 API,建议 OSS 用户使用 CDN 回源 OSS 来代替。

使用 Menci/deploy-certificate-to-aliyun 将证书部署到阿里云:

- name: Deploy certificate
  uses: Menci/de[email protected]
  with:
    access-key-id: ${{ secrets.ALIYUN_ACCESS_KEY_ID }}
    access-key-secret: ${{ secrets.ALIYUN_ACCESS_KEY_SECRET }}
    fullchain-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
    key-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}
    certificate-name: ${{ matrix.certificate-name }}
    cdn-domains: ${{ join(matrix.cdn-domains, ' ') }}

其中 certificate-name 指定上传的证书在证书服务中的名称(将自动替换旧版本),cdn-domain 指定需要将该证书部署到的 CDN 域名列表(用空白字符隔开)。

建议使用子账户 Access Key,为其赋予以下权限(并按需使用资源组隔离):

  • AliyunYundunCertFullAccess
  • AliyunCDNFullAccess
  • AliyunPCDNFullAccess
  • AliyunSCDNFullAccess
  • AliyunDCDNFullAccess

又拍云

又拍云的证书服务支持上传自定义证书,该证书可以用于又拍云所有服务。

使用 Menci/deploy-certificate-to-upyun 将证书部署到又拍云:

- name: Deploy certificate
  uses: Menci/[email protected]
  with:
    subaccount-username: ${{ secrets.UPYUN_SUBACCOUNT_USERNAME }}
    subaccount-password: ${{ secrets.UPYUN_SUBACCOUNT_PASSWORD }}
    fullchain-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
    key-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}
    domains: ${{ join(matrix.domains, ' ') }}
    delete-unused-certificates: true

其中 domains 指定需要将该证书部署到的域名列表(用空白字符隔开)。当 delete-unused-certificatestrue 时,将自动删除所有未使用的旧证书(包含非本 Action 上传的证书,以及在又拍云申请的证书)。

由于又拍云没有提供证书相关的公开 API,该 Action 使用控制台 API 实现,所以需要提供子账户的用户名和密码,而非 Access Key 和 Secret Key。

服务器

通过 GitHub Actions 自动向服务器部署证书较为困难,特别是对于位于 NAT 后的服务器而言。我的解决方法是让服务器自动从 Azure Key Vault 中拉取证书。如果你不使用 Azure Key Vault,也可以考虑直接从 GitHub 仓库中拉取证书(使用只读的 Deploy Keys 可以达到权限最小化,但只能控制到仓库级别,无法控制到分支级别)。

下载证书所用的 Service Principal 需要拥有该 Key Vault 的 Secret → Get 权限(而非 Certificate → Get 权限,该权限仅可读取证书本身,不可读取证书私钥)。与上文同理,创建一个 Service Principal 并赋予权限(建议使用不同的 Service Principal 上传和下载证书),并在服务器上登录(其中 username 即为 appId):

az login --service-principal \
           --username 218acab1-8f2d-4f0c-ab30-8931332058d3 \
           --password 'fbrDxvS0~mWQ8QcHVLQzqAuJhOHDR9-YW4' \
           --tenant 72f988bf-86f1-41af-91ab-2d7cd011db47

登录 Azure CLI 后,使用以下脚本自动从 Azure Key Vault 中拉取证书(记得 chmod +x):

#!/bin/bash -e

AZURE_CERT_URI="https://<你的 Key Vault 名称>.vault.azure.net/secrets/<你的 Key Vault 中证书的名称>"
INSTALL_KEY="/etc/nginx/ssl/ssl.key"  # PEM 私钥的目标位置
INSTALL_CERT="/etc/nginx/ssl/ssl.crt" # PEM 全链的目标位置
INSTALL_CMD="systemctl reload nginx"  # 应用证书的命令

rm -rf /tmp/update-cert
mkdir /tmp/update-cert
cd /tmp/update-cert

# 下载 PFX 格式的证书
az keyvault secret download --id "$AZURE_CERT_URI" --encoding base64 --file cert.pfx

# 转换为 PEM 格式
openssl pkcs12 -in cert.pfx -nocerts -out key-enc.pem -passin pass: -passout pass:pass
openssl pkcs12 -in cert.pfx -nokeys -out cert.pem -passin pass:
openssl rsa -in key-enc.pem -out key.pem -passin pass:pass

cp key.pem "$INSTALL_KEY"
cp cert.pem "$INSTALL_CERT"

$INSTALL_CMD

将该脚本添加到 cron 即可定期运行,如:

0 20 * * * /root/update-cert.sh

完整例子

这里给出一个完整的例子,即我目前使用的配置。在 main/optionsmain/domains 文件中指定参数和域名列表,在 main/deploy.json 中指定部署目标,每一类部署目标可以有多个(主要是 Azure App Service)。

GitHub Workflow 文件(将在每周三、周六的 UTC+0 零点自动更新证书):

name: Main
on:
  workflow_dispatch:
  schedule:
    - cron: '0 0 * * SAT'
    - cron: '0 0 * * WED'
env:
  TARGET: main
  CERTS_OUTPUT_DIRECTORY: certs-output
  FILE_FULLCHAIN: fullchain.pem
  FILE_KEY: key.pem
  FILE_PFX: certificate.pfx
  PFX_PASSWORD: qwq
jobs:
  issue-push:
    name: Issue & Push
    runs-on: ubuntu-latest
    steps:
    - name: Checkout
      uses: actions/[email protected]
      with:
        ref: main
    - name: Checkout output branch
      uses: actions/[email protected]
      with:
        ref: certs-${{ env.TARGET }}
        path: ${{ env.CERTS_OUTPUT_DIRECTORY }}
    - name: Issue certificate
      uses: Menci/[email protected]
      with:
        version: 83da01a2e1f5384ed997f9e023ea4a813dcac1f0
        account-tar: ${{ secrets.ACME_SH_ACCOUNT_TAR }}
        domains-file: ${{ env.CONFIG_DOMAINS }}
        arguments-file: ${{ env.CONFIG_OPTIONS }}
        output-fullchain: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
        output-key: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}
        output-pfx: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_PFX }}
        output-pfx-password: ${{ env.PFX_PASSWORD }}
      env:
        CONFIG_DOMAINS: ${{ env.TARGET }}/domains
        CONFIG_OPTIONS: ${{ env.TARGET }}/options
    - name: Push to GitHub
      run: |
        git config --global user.name $(git show -s --format='%an' HEAD)
        git config --global user.email $(git show -s --format='%ae' HEAD)
        cd "$CERTS_OUTPUT_DIRECTORY"
        git add "$FILE_KEY" "$FILE_FULLCHAIN" "$FILE_PFX"
        git commit -m "[Actions/${{ github.workflow }}] Upload certificates on $(date '+%Y-%m-%d %H:%M:%S')"
        git push
      env:
        TZ: Asia/Shanghai
  deployment-config:
    name: Load Deployment Config
    runs-on: ubuntu-latest
    outputs:
      config: ${{ steps.read-deployment-config.outputs.config }}
    steps:
    - name: Checkout
      uses: actions/[email protected]
      with:
        ref: main
    - name: Read Deployment Config
      id: read-deployment-config
      run: |
        echo "::set-output name=config::$(jq -c < "$CONFIG_FILE")"
      env:
        CONFIG_FILE: ${{ env.TARGET }}/deploy.json
  deploy-azure-keyvault:
    name: Deploy to Azure KeyVault (${{ matrix.vault-name }})
    runs-on: ubuntu-latest
    needs: [issue-push, deployment-config]
    if: ${{ fromJson(needs.deployment-config.outputs.config).azure-keyvault }}
    strategy:
      fail-fast: false
      matrix:
        include: ${{ fromJson(needs.deployment-config.outputs.config).azure-keyvault }}
    steps:
    - name: Checkout output branch
      uses: actions/[email protected]
      with:
        ref: certs-${{ env.TARGET }}
        path: ${{ env.CERTS_OUTPUT_DIRECTORY }}
    - name: Login to Azure
      uses: azure/[email protected]
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }}
    - name: Upload certificate
      uses: azure/[email protected]
      with:
        inlineScript: |
          az keyvault certificate import --subscription "$AZURE_SUBSCRIPTION" \
                                         --vault-name "$AZURE_KEY_VAULT_NAME" \
                                         --file "$CERTS_OUTPUT_DIRECTORY/$FILE_PFX" \
                                         --name "$AZURE_KEY_CERTIFICATE_NAME" \
                                         --password "$PFX_PASSWORD"
      env:
        AZURE_SUBSCRIPTION: ${{ matrix.subscription }}
        AZURE_KEY_VAULT_NAME: ${{ matrix.vault-name }}
        AZURE_KEY_CERTIFICATE_NAME: ${{ matrix.certificate-name }}
  deploy-azure-webapp:
    name: Deploy to Azure Web App (${{ matrix.name }})
    runs-on: ubuntu-latest
    needs: [issue-push, deployment-config]
    if: ${{ fromJson(needs.deployment-config.outputs.config).azure-webapp }}
    strategy:
      fail-fast: false
      matrix:
        include: ${{ fromJson(needs.deployment-config.outputs.config).azure-webapp }}
    steps:
    - name: Checkout output branch
      uses: actions/[email protected]
      with:
        ref: certs-${{ env.TARGET }}
        path: ${{ env.CERTS_OUTPUT_DIRECTORY }}
    - name: Deploy certificate
      uses: Menci/[email protected]
      with:
        azcliversion: 2.28.0
        creds: ${{ secrets.AZURE_CREDENTIALS }}
        subscription: ${{ matrix.subscription }}
        resource-group: ${{ matrix.resource-group }}
        webapp-name: ${{ matrix.webapp-name }}
        certificate-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_PFX }}
        certificate-password: ${{ env.PFX_PASSWORD }}
        delete-old-certificates: true
  deploy-aliyun:
    name: Deploy to Aliyun (${{ matrix.certificate-name }})
    runs-on: ubuntu-latest
    needs: [issue-push, deployment-config]
    if: ${{ fromJson(needs.deployment-config.outputs.config).aliyun }}
    strategy:
      fail-fast: false
      matrix:
        include: ${{ fromJson(needs.deployment-config.outputs.config).aliyun }}
    steps:
    - name: Checkout output branch
      uses: actions/[email protected]
      with:
        ref: certs-${{ env.TARGET }}
        path: ${{ env.CERTS_OUTPUT_DIRECTORY }}
    - name: Deploy certificate
      uses: Menci/[email protected]
      with:
        access-key-id: ${{ secrets.ALIYUN_ACCESS_KEY_ID }}
        access-key-secret: ${{ secrets.ALIYUN_ACCESS_KEY_SECRET }}
        fullchain-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
        key-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}
        certificate-name: ${{ matrix.certificate-name }}
        cdn-domains: ${{ join(matrix.cdn-domains, ' ') }}
  deploy-upyun:
    name: Deploy to Upyun (${{ matrix.name }})
    runs-on: ubuntu-latest
    needs: [issue-push, deployment-config]
    if: ${{ fromJson(needs.deployment-config.outputs.config).upyun }}
    strategy:
      fail-fast: false
      matrix:
        include: ${{ fromJson(needs.deployment-config.outputs.config).upyun }}
    steps:
    - name: Checkout output branch
      uses: actions/[email protected]
      with:
        ref: certs-${{ env.TARGET }}
        path: ${{ env.CERTS_OUTPUT_DIRECTORY }}
    - name: Deploy certificate
      uses: Menci/[email protected]
      with:
        subaccount-username: ${{ secrets.UPYUN_SUBACCOUNT_USERNAME }}
        subaccount-password: ${{ secrets.UPYUN_SUBACCOUNT_PASSWORD }}
        fullchain-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
        key-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}
        domains: ${{ join(matrix.domains, ' ') }}
        delete-unused-certificates: true

部署配置(main/deploy.json)的格式:

{
  "azure-keyvault": [
    {
      "subscription": "",
      "vault-name": "",
      "certificate-name": ""
    }
  ],
  "azure-webapp": [
    {
      "name": "<显示名称>",
      "subscription": "",
      "resource-group": "",
      "webapp-name": ""
    }
  ],
  "aliyun": [
    {
      "certificate-name": "",
      "cdn-domains": [
        "example.com",
        "example.net"
      ]
    }
  ],
  "upyun": [
    {
      "name": "<显示名称>",
      "domains": [
        "example.com",
        "example.net"
      ]
    }
  ]
}

关于 GTS 证书

最近 Google Cloud 推出了基于 Google Trust Service(GTS)的 SSL 证书服务,可参考此文章存档)进行申请。acme.sh 也在第一时间添加了对 GTS 的支持。

GTS 证书的亮点有:

  1. 使用与 Google.com 相同的根证书 GTS Root R1,交叉签名了有效期从 1998 至 2028 的 GlobalSign Root CA,兼容性较好。而 Let's Encrypt 的根证书 ISRG Root X1 所交叉签名的 DST Root CA X3 已过期,仅有的 ISRG Root X1 在一些旧设备和未更新的系统上未被信任。
  2. 支持自定义证书有效期,可以设置有效期 1 天到 90 天。
  3. 其 OCSP 服务在中国大陆设有节点,国内查询速度较快。

而 GTS 相较于 Let's Encrypt 的不足之处有:

  1. 不支持使用 Punycode 编码的域名(即含有中文、Emoji 等字符的域名)。
  2. 由于其根证书 GTS Root R1 和中间证书 GTS CA 1P5 均为 RSA 证书,GTS 无法提供全链 ECC 证书。而 Let's Encrypt 可以通过 ISRG Root X2 提供。

由于 Firefox 不会检验有效期在 10 天内的证书的 OCSP 状态(而 Chrome 默认对任何证书均不检查),所以,申请 10 天以内的证书可能能够提升网站的访问速度(特别是在无法使用 OCSP stapling 时)。