使用 GitHub Actions 自动申请与部署 SSL 证书

使用 GitHub Actions 自动申请与部署 SSL 证书

技术向约 2.9 千字

对于一个有很多服务器的人来说,在不同服务器上同步 SSL 证书是一件麻烦事。笔者尝试过很多种方式,最后在 Menci 的推荐下选定了使用 GitHub Actions 来自动申请、续期 SSL 证书,并自动推送到各个服务器上。

本博客的证书也是使用这种方式进行签发、部署的,可以点击浏览器地址栏上的按钮查看证书。

申请证书

前期准备

首先请在本地(或自己的服务器上)成功使用 acme.shDNS-01 验证方式成功申请一次证书,如果不会操作的话可以参考 烧饼博客的教程 来进行。这个过程包括:

  1. 向 CA 注册 ACME 账户(如果使用 Let’s Encrypt 则会自动进行,详细步骤请参阅 acme.sh 的 Wiki)。
  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 中。注意不要复制输出中的多余信息。

自动化

如果没有特殊需求,可以使用 Menci/acme 来简单地申请证书:

# 全局环境变量
env:
  # Checkout 到的目录
  CERTS_OUTPUT_BASE: certs
  # 证书输出目录
  CERTS_OUTPUT_DIRECTORY: example.com
  # 证书文件名
  FILE_FULLCHAIN: fullchain.pem
  # 私钥文件名
  FILE_KEY: privatekey.key

jobs:
  issue-ssl-certificate:
    name: Issue SSL certificate
    runs-on: ubuntu-latest
    steps:
      - uses: Menci/[email protected]
        with:
          # 指定 acme.sh 的版本
          version: 3.0.2

          # 上方保存的以 Base64 编码存储的凭据
          account-tar: ${{ secrets.ACME_SH_ACCOUNT_TAR }}

          # 域名列表,以空格分隔
          domains: example.com example.net example.org example.edu
          # 是否申请通配符
          append-wildcard: true

          # 传递给 acme.sh 的额外参数
          arguments: --dns dns_cf --challenge-alias example.com

          # 导出的证书路径
          output-fullchain: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
          output-key: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}

如果需要高度自定义 acme.sh 的参数,比如为不同的域名设置不同的 DNS 提供商,可以使用下面的方式手动编写命令来执行:

# 全局环境变量
env:
  # Checkout 到的目录
  CERTS_OUTPUT_BASE: certs
  # 证书输出目录
  CERTS_OUTPUT_DIRECTORY: example.com
  # 证书文件名
  FILE_FULLCHAIN: fullchain.pem
  # 私钥文件名
  FILE_KEY: privatekey.key

jobs:
  issue-ssl-certificate:
    name: Issue SSL certificate
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/[email protected]
        with:
          ref: master

      - name: Checkout output branch
        uses: actions/[email protected]
        with:
          ref: certs
          path: ${{ env.CERTS_OUTPUT_BASE }}

      # 安装 acme.sh
      - name: Install acme.sh
        shell: bash
        run: curl -s https://get.acme.sh | sh

      # 解压 acme.sh 配置信息
      - name: Extract account files for acme.sh
        shell: bash
        run: |
          echo "$ACME_SH_ACCOUNT_TAR" | base64 -d | tar -C ~/.acme.sh -xz
        env:
          # Base64 编码的 acme.sh 配置信息
          ACME_SH_ACCOUNT_TAR: ${{ secrets.ACME_SH_ACCOUNT_TAR }}

      # 申请证书
      - name: Issue SSL certificates
        shell: bash
        run: |
          ~/.acme.sh/acme.sh --issue        \
            -d "example.com"   --dns dns_cf \
            -d "*.example.com" --dns dns_cf \
            -d "example.net"   --dns dns_dp \
            -d "*.example.net" --dns dns_dp \
            --server letsencrypt

      # 导出证书
      - name: Copy certificate to output paths
        shell: bash
        run: |
          ACME_SH_TEMP_DIR="$(mktemp -d)"
          ACME_SH_TEMP_FILE_FULLCHAIN="$ACME_SH_TEMP_DIR/fullchain.pem"
          ACME_SH_TEMP_FILE_KEY="$ACME_SH_TEMP_DIR/key.pem"

          ~/.acme.sh/acme.sh --install-cert -d "$ACME_SH_FIRST_DOMAIN" --fullchain-file "$ACME_SH_TEMP_FILE_FULLCHAIN" --key-file "$ACME_SH_TEMP_FILE_KEY"

          [[ -z "$ACME_SH_OUTPUT_FULLCHAIN" ]] || (mkdir -p "$(dirname "$ACME_SH_OUTPUT_FULLCHAIN")" && cp "$ACME_SH_TEMP_FILE_FULLCHAIN" "$ACME_SH_OUTPUT_FULLCHAIN")
          [[ -z "$ACME_SH_OUTPUT_KEY" ]] || (mkdir -p "$(dirname "$ACME_SH_OUTPUT_KEY")" && cp "$ACME_SH_TEMP_FILE_KEY" "$ACME_SH_OUTPUT_KEY")

          rm -rf "$ACME_SH_TEMP_DIR"
        env:
          # 修改此处的 example.com 为申请时填写的第一个域名
          ACME_SH_FIRST_DOMAIN: example.com
          ACME_SH_OUTPUT_FULLCHAIN: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
          ACME_SH_OUTPUT_KEY: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}

上传证书至仓库

# 上传证书
- name: Push to GitHub
  run: |
    git config --global user.name "BaoshuoBot"
    git config --global user.email "[email protected]"

    cd "$CERTS_DIRECTORY"

    git add "$FILE_FULLCHAIN" "$FILE_KEY"
    git commit -m "Upload certificates on $(date '+%Y-%m-%d %H:%M:%S')"
    git push
  env:
    TZ: Asia/Shanghai
    CERTS_DIRECTORY: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}

部署证书

在申请证书的 Job 执行完成后,可以执行一系列其他的 Job 来将证书部署到各个服务器或云服务。

服务器

可以使用 easingthemes/ssh-deploy 来使用 rsync 将证书同步到服务器上。同步完成后再使用 appleboy/ssh-action 远程执行命令重载 Nginx / Apache。

# 部署到服务器
deploy-to-server:
  name: Deploy Certificate to Server
  runs-on: ubuntu-latest
  needs: issue-ssl-certificate

  strategy:
    matrix:
      host:
        - 174.136.239.1 # Server 1
        - 174.136.239.2 # Server 2
        # ...
        - 174.136.239.254 # Server N

  steps:
    - name: Checkout
      uses: actions/[email protected]
      with:
        ref: certs

    # 上传证书
    - name: Upload certificate to server
      uses: easingthemes/[email protected]
      env:
        SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
        ARGS: '-avz --delete'
        REMOTE_HOST: ${{ matrix.host }}
        REMOTE_USER: ${{ secrets.REMOTE_USER }}
        SOURCE: ${{ env.CERTS_OUTPUT_DIRECTORY }}/
        TARGET: /path/to/ssl/certs/${{ env.CERTS_OUTPUT_DIRECTORY }}/

    # 重载 Nginx
    - name: Force-reload nginx
      uses: appleboy/[email protected]
      with:
        host: ${{ matrix.host }}
        username: ${{ secrets.REMOTE_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        script: |
          sudo /opt/hooks/reload-nginx.sh

需要注意的是,重载 Nginx / Apache 的命令需要 root 权限才能执行,可以采用只允许部署用户以 root 权限执行重载脚本的方式来避免出现安全问题。

/opt/hooks 目录下新建一个文件 reload-nginx.sh,内容如下:

#!/bin/bash
sudo systemctl force-reload nginx

然后新建一个名为 actions-cert 的用户,然后在 /etc/sudoers 文件中添加以下内容:

actions-cert ALL=(ALL) NOPASSWD: /opt/hooks/reload-nginx.sh

这个配置可以使 actions-cert 用户免密码以 root 用户的权限执行 /opt/hooks/reload-nginx.sh

最后使用 chmod 755 /opt/hooks/reload-nginx.sh 命令将 reload-nginx.sh 文件设置为可执行,同时禁止非所有者对其进行写入操作。

如果服务器位于 NAT 后,或者禁止了 SSH 连接,还有两个方法可以将证书部署到内网服务器上:

  1. 将证书先部署到有部署条件的服务器上,然后再在内网服务器上使用 rsync 从部署好的服务器上拉取证书。
  2. 将证书上传到 Azure Key Vault 等托管服务中,再在服务器上按照 Menci 的文章 中的教程拉取即可。

阿里云

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

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

# 部署到阿里云
deploy-to-aliyun:
  name: Deploy Certificate to Aliyun
  runs-on: ubuntu-latest
  needs: issue-ssl-certificate

  steps:
    # 拉取证书存储分支
    - name: Checkout
      uses: actions/[email protected]
      with:
        ref: certs

    # 上传证书
    - name: Deploy certificate to aliyun
      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: example.com
        cdn-domains: |
          example.com
          example.net

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

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

  • AliyunYundunCertFullAccess
  • AliyunCDNFullAccess
  • AliyunPCDNFullAccess
  • AliyunSCDNFullAccess
  • AliyunDCDNFullAccess

腾讯云

使用 renbaoshuo/deploy-certificate-to-tencentcloud 将证书部署至腾讯云 CDN:

deploy-to-qcloud-cdn:
  name: Deploy certificate to Tencent Cloud CDN
  runs-on: ubuntu-latest
  needs: issue-ssl-certificate

  steps:
    - name: Check out
      uses: actions/[email protected]
      with:
        # If you just commited and pushed your newly issued certificate to this repo in a previous job,
        # use `ref` to make sure checking out the newest commit in this job
        ref: ${{ github.ref }}

    - uses: renbaoshuo/[email protected]
      with:
        # Use Access Key
        secret-id: ${{ secrets.QCLOUD_SECRET_ID }}
        secret-key: ${{ secrets.QCLOUD_SECRET_KEY }}

        # Specify PEM fullchain file
        fullchain-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
        # Specify PEM private key file
        key-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}

        # Deploy to CDN
        cdn-domains: |
          cdn1.example.com
          cdn2.example.com

其中 cdn-domains 指定需要将该证书部署到的 CDN 域名列表(用空白字符隔开)。

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

  • QcloudCDNFullAccess

完整例子

这个 Action 完成了以下操作:

  1. 申请证书,并上传到仓库的 certs 分支。
  2. 在申请证书后将 certs 分支中的证书部署到服务器上。
# 名称
name: Issue SSL Certificates

# 触发条件
on:
  # 手动运行
  workflow_dispatch:
  # 定时运行
  schedule:
    # 每两个月运行一次
    - cron: '0 0 1 */2 *'

# 全局环境变量
env:
  # Checkout 到的目录
  CERTS_OUTPUT_BASE: certs
  # 证书输出目录
  CERTS_OUTPUT_DIRECTORY: example.com
  # 证书文件名
  FILE_FULLCHAIN: fullchain.pem
  # 私钥文件名
  FILE_KEY: privatekey.key

jobs:
  issue-ssl-certificate:
    # 申请证书并 push 到 certs 分支
    name: Issue SSL certificate
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/[email protected]
        with:
          ref: master

      - name: Checkout output branch
        uses: actions/[email protected]
        with:
          ref: certs
          path: ${{ env.CERTS_OUTPUT_BASE }}

      # 安装 acme.sh
      - name: Install acme.sh
        shell: bash
        run: curl -s https://get.acme.sh | sh

      # 解压 acme.sh 配置信息
      - name: Extract account files for acme.sh
        shell: bash
        run: |
          echo "$ACME_SH_ACCOUNT_TAR" | base64 -d | tar -C ~/.acme.sh -xz
        env:
          # Base64 编码的 acme.sh 配置信息
          ACME_SH_ACCOUNT_TAR: ${{ secrets.ACME_SH_ACCOUNT_TAR }}

      # 申请证书
      - name: Issue SSL certificates
        shell: bash
        run: |
          ~/.acme.sh/acme.sh --issue            \
            -d "example.com" -d "*.example.com" \
            --dns dns_cf --server letsencrypt

      # 导出证书
      - name: Copy certificate to output paths
        shell: bash
        run: |
          ACME_SH_TEMP_DIR="$(mktemp -d)"
          ACME_SH_TEMP_FILE_FULLCHAIN="$ACME_SH_TEMP_DIR/fullchain.pem"
          ACME_SH_TEMP_FILE_KEY="$ACME_SH_TEMP_DIR/key.pem"

          # 不要忘记修改这里的 -d 参数值为上方的第一个域名
          ~/.acme.sh/acme.sh --install-cert -d "example.com" --fullchain-file "$ACME_SH_TEMP_FILE_FULLCHAIN" --key-file "$ACME_SH_TEMP_FILE_KEY"

          [[ -z "$ACME_SH_OUTPUT_FULLCHAIN" ]] || (mkdir -p "$(dirname "$ACME_SH_OUTPUT_FULLCHAIN")" && cp "$ACME_SH_TEMP_FILE_FULLCHAIN" "$ACME_SH_OUTPUT_FULLCHAIN")
          [[ -z "$ACME_SH_OUTPUT_KEY" ]] || (mkdir -p "$(dirname "$ACME_SH_OUTPUT_KEY")" && cp "$ACME_SH_TEMP_FILE_KEY" "$ACME_SH_OUTPUT_KEY")

          rm -rf "$ACME_SH_TEMP_DIR"
        env:
          ACME_SH_OUTPUT_FULLCHAIN: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
          ACME_SH_OUTPUT_KEY: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}

      # 上传证书
      - name: Push to GitHub
        run: |
          git config --global user.name "BaoshuoBot"
          git config --global user.email "[email protected]"

          cd "$CERTS_DIRECTORY"

          git add "$FILE_FULLCHAIN" "$FILE_KEY"
          git commit -m "Upload certificates on $(date '+%Y-%m-%d %H:%M:%S')"
          git push
        env:
          TZ: Asia/Shanghai
          CERTS_DIRECTORY: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}

  # 部署证书到服务器
  deploy-to-server:
    name: Deploy Certificate to Server
    runs-on: ubuntu-latest
    needs: issue-ssl-certificate

    strategy:
      matrix:
        host:
          - 174.136.239.1 # Server 1
          - 174.136.239.2 # Server 2
          # ...
          - 174.136.239.254 # Server N

    steps:
      - name: Checkout
        uses: actions/[email protected]
        with:
          ref: certs

      # 上传证书
      - name: Upload certificate to server
        uses: easingthemes/[email protected]
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          ARGS: '-avz --delete'
          REMOTE_HOST: ${{ matrix.host }}
          REMOTE_USER: ${{ secrets.REMOTE_USER }}
          SOURCE: ${{ env.CERTS_OUTPUT_DIRECTORY }}/
          TARGET: /path/to/ssl/certs/${{ env.CERTS_OUTPUT_DIRECTORY }}/

      # 重载 Nginx
      - name: Force-reload nginx
        uses: appleboy/[email protected]
        with:
          host: ${{ matrix.host }}
          username: ${{ secrets.REMOTE_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            sudo /opt/hooks/reload-nginx.sh

杂项

部分情况下,GitHub Actions 中的 GITHUB_TOKEN 只有 Read repository contents permission,而本文中的 Actions 要求这个 Token 具有 Read and write permissions,那么需要在仓库的 Settings > Actions > General 页面的底部赋予其写入权限,如图所示:

设置好后点击 Save 按钮即可。

参考资料

  1. 使用 GitHub Actions 自动申请与部署 ACME SSL 证书,Menci,2022 年 5 月 11 日。对原文章内容的使用已经过作者同意。
  2. 使用 acme.sh 配置自动续签 SSL 证书,烧饼博客,2022 年 2 月 3 日。

文章头图由 Menci 制作,使用已经过授权,在此表示感谢。

使用 GitHub Actions 自动申请与部署 SSL 证书
本文作者
发布于
更新于
版权协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
喜欢这篇文章?为什么不考虑打赏一下作者呢?
爱发电