本文参考(复制)自:使用 GitHub Actions 自动申请与部署 ACME SSL 证书

准备

本人使用的是FreeSSL提供的ACME 自动化

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

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

第一次申请证书后,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-wildcard 为 false。
- 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 到仓库中

首先创建一个空的分支:

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

申请到的证书会放到这个分支下

部署证书

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

由于我这边只用到又拍云,所以需要其他云服务的请参考原文

点击查看原文内容

Azure Key Vault

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

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

- name: Login to Azure
  uses: azure/login@v1
  with:
    creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Upload certificate
  uses: azure/CLI@v1
  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/deploy-certificate-to-azure-web-app@beta-v2
  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/deploy-certificate-to-aliyun@beta-v1
  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

服务器

通过 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

又拍云

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

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

- name: Deploy certificate
  uses: Menci/deploy-certificate-to-upyun@beta-v2
  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 指定需要将该证书部署到的域名列表(用空白字符隔开)。
不需要修改此处,请查看下面的demo

当 delete-unused-certificates 为 true 时,将自动删除所有未使用的旧证书(包含非本 Action 上传的证书,以及在又拍云申请的证书)。

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

完整DEMO

这里给出一个完整的例子.

仓库文件结构:

├─.github
│  └─workflows
│       workflows.yml
└─main
        deploy.json
        domains
        options

main/domains 文件中指定域名列表(一行一个,把想在证书上展示的域名写在第一位)(根域名包含泛域名,如不需要请在上文配置项修改)

example.com
example.net

main/options 文件中指定参数(即:第一次运行acme的参数-去掉域名)

--issue --dns dns_dp --server https://acme.freessl.cn/xxxxxxxx

main/deploy.json 中指定部署目标,每一类部署目标可以有多个

{
  "upyun": [
    {
      "name": "github_AutoSSL",
      "domains": [
        "example.com",
        "example.net",
        "*.example.com",
        "*.example.net",
        "www.example.com",
        "abc.example.net" 
        // !这里需要填写所有域名,使用泛域名也需要填写子域名
      ]
    }
  ]
}

GitHub Workflow 文件(每两个月自动更新):

此文件注意配置ACCOUNT_TAR

name: ACME自动化
on:
  workflow_dispatch:
  schedule:
    - cron: '0 0 1 */2 *' # 每两个月执行一次
    # - cron: '0 0 * * WED'
env:
  TARGET: main # 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: 申请并推送证书
    runs-on: ubuntu-latest
    steps:
    - name: Checkout
      uses: actions/checkout@v2
      with:
        ref: main
    - name: Checkout output branch
      uses: actions/checkout@v2
      with:
        ref: certs-${{ env.TARGET }} # 分支的名字 certs-main !分支必须存在
        path: ${{ env.CERTS_OUTPUT_DIRECTORY }}
    - name: 申请证书
      uses: Menci/acme@main
      with:
        version: 83da01a2e1f5384ed997f9e023ea4a813dcac1f0
        account-tar: ${{ secrets.ACCOUNT_TAR }} # ACME的base64密钥
        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 # 对应上面 main分支main文件夹下的domains文件,一行一个域名,只写根域名即可包含泛域名
        CONFIG_OPTIONS: ${{ env.TARGET }}/options # 对应上面 main分支main文件夹下的options文件,主要写入你的server配置,即:第一次运行acme的参数-去掉域名
    - name: 推送到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: 加载Deploy配置文件
    runs-on: ubuntu-latest
    outputs:
      config: ${{ steps.read-deployment-config.outputs.config }}
    steps:
    - name: Checkout
      uses: actions/checkout@v2
      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 # 对应上面 main分支main文件夹下的deploy.json文件
  deploy-upyun:
    name: 部署到又拍云 - (${{ 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/checkout@v2
      with:
        ref: certs-${{ env.TARGET }}
        path: ${{ env.CERTS_OUTPUT_DIRECTORY }}
    - name: Deploy certificate
      uses: Menci/deploy-certificate-to-upyun@main
      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

全部部署完成后,仓库至少有一个分支,然后运行一下workflow,体验一下全自动部署吧 :)

Q.E.D.