POST

为 GitHub Actions 配置腾讯云专用部署用户

为 GitHub Actions 配置腾讯云专用部署用户
最近在公司实习的期间,在我的腾讯云轻量服务器上跑着的一个项目,然后我算真正意义的完整尝试了 CICD 的流程,感觉非常震撼,好用,同样也遇到了今天的问题

前情提要

我在 codex 的辅助下设计了一套工作流,提交到了 github 都会触发一次 actions,具体 actions 内容如下。

name: Deploy production

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: luopan-production
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  ci:
    name: Validate code and deployment configuration
    runs-on: ubuntu-latest
    steps:
      - name: Check out committed source
        uses: actions/checkout@v6
      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: '3.11'
      - name: Install uv
        run: |
          curl -LsSf https://astral.sh/uv/0.11.29/install.sh | \
            env UV_UNMANAGED_INSTALL="$HOME/.local/bin" sh
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - name: Sync locked Python environment
        run: uv sync --locked --no-dev
      - name: Run Python syntax and unit checks
        run: |
          uv run --locked python -m compileall -q apps tests
          uv run --locked python -m unittest discover -s tests -v
      - name: Run Rust tests
        run: cargo test --workspace
      - name: Set up Node
        uses: actions/setup-node@v6
        with:
          node-version: '20'
      - name: Set up pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 10.24.0
      - name: Install frontend dependencies
        working-directory: apps/web
        run: pnpm install --frozen-lockfile
      - name: Check and build frontend
        working-directory: apps/web
        run: |
          pnpm check
          pnpm build
      - name: Check JavaScript and shell scripts
        run: |
          node --check web/static/app.js
          bash -n ops/deploy.sh ops/backup.sh ops/ssh-deploy-wrapper.sh \
            docker/start.sh docker/cron_scrape.sh docker/cron_inventory_sync.sh \
            docker/run_daily.sh docker/test-deploy.sh \
            scripts/run_daily.sh scripts/run_dashboard.sh scripts/run_inventory_sync.sh \
            scripts/deploy_from_github.sh
      - name: Validate Docker Compose production mounts
        run: |
          docker compose config >/dev/null
          LUOPAN_DATA_DIR=/srv/luopan-data docker compose config | \
            grep -q '/srv/luopan-data/state'
          grep -q '^STORAGE_SYNC_AFTER_SCRAPE=true$' docker/crontab
          grep -q 'STORAGE_SYNC_AFTER_SCRAPE:-true' docker/cron_scrape.sh
          for runtime_dir in config logs output session state; do
            grep -q -- "--exclude='${runtime_dir}/'" ops/deploy.sh
          done
          grep -q -- '--alias douyin-compass-collector' ops/deploy.sh

  deploy:
    needs: ci
    runs-on: ubuntu-latest
    steps:
      - name: Configure restricted deployment key
        env:
          DEPLOY_KEY: ${{ secrets.LUOPAN_DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.LUOPAN_DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          chmod 600 ~/.ssh/known_hosts
      - name: Deploy committed main branch
        env:
          DEPLOY_HOST: ${{ secrets.LUOPAN_DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.LUOPAN_DEPLOY_USER }}
        run: |
          ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=yes \
            -o ServerAliveInterval=30 -o ServerAliveCountMax=20 \
            "${DEPLOY_USER}@${DEPLOY_HOST}" "deploy ${GITHUB_SHA}"

其实对于我来说这个项目的 CI 基本上都是走个流程,因为如果本地运行不通过的话,我都不会提交上去。所以主要看 CD 部分。

  deploy:
    needs: ci
    runs-on: ubuntu-latest
    steps:
      - name: Configure restricted deployment key
        env:
          DEPLOY_KEY: ${{ secrets.LUOPAN_DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.LUOPAN_DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          chmod 600 ~/.ssh/known_hosts
      - name: Deploy committed main branch
        env:
          DEPLOY_HOST: ${{ secrets.LUOPAN_DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.LUOPAN_DEPLOY_USER }}
        run: |
          ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=yes \
            -o ServerAliveInterval=30 -o ServerAliveCountMax=20 \
            "${DEPLOY_USER}@${DEPLOY_HOST}" "deploy ${GITHUB_SHA}"

注意,需要在本地生成一对公钥和私钥,公钥放到服务器,私钥则放到 GitHub 的 actions 的 env 中。通过以下命令来生成密钥对:

ssh-keygen -t ed25519 -f ~/.ssh/key_name -C "comment_message"
截屏2026-07-27 18.23.11.png

公钥要放到服务器的~/.ssh/authorized_keys,然后就可以免密登录了,也可以成功 CD 了。

问题描述

现在是能成功部署的,但是问题是,每次提交触发 actions 都给我弹这样的消息。

mosaic-tskau.png

原因也很明显,GitHub actions 的部署是在 Azure 的服务器上,而这些服务器又是美国的,我的部署命令也是通过ssh访问服务器来实现的。频繁的部署就会频繁的发,这样还是有点烦的。

问题解决

这个问题如何解决呢,我问过很多的 ai,给出的方案都是差不多的,一个是指定CICD 使用的用户名(腾讯云上给这个用户添加到白名单),另一个就是通过 cloudflare tunnel 连接,本文中使用的是方法一。

确认报警源

第一步,你收到任何的报警信息要先确定是不是自己触发的,通过核对登录时间和 actions 操作时间来判断。

不要把陌生人的恶意访问当成 actions。

创建专用账号

连接到服务器,通过以下命令来创建一个全新的用户:

sudo adduser \
  --disabled-password \
  --gecos "" \
  user-name

sudo passwd -l uaer-name

值得注意的是,由于我们之后要将这个用户加到白名单,所有要放行一切登录行为,为了避免被恶意攻击,所以我们关闭密码登录功能,只能通过密钥来登录。

单独创建密钥

为了避免因为密钥泄露导致的无限制恶意访问,所以最好单独创建一个新的密钥。

在自己的电脑本地:

ssh-keygen \
  -t ed25519 \
  -f ~/.ssh/github_actions \
  -C "github-actions" \
  -N ""

-f 是位置和密钥名字;-t 是密钥类型,通常使用ed25519-C 是备注信息。

和上面一样会得到两个密钥

  • 私钥 → GitHub Actions Secret
  • 公钥 → 服务器的 authorized_keys
image.png

注意这里的 secrets 的 name 要和上面 actions.yml 中对应。

给新建用户提权

新建的用户默认是不可能有权限去运行另一个用户文件夹内的文件的,所以我们需要给这个用户赋予一定的权限。

有两种方式,一种是给他赋项目文件夹和 docker(我的项目需要启动 docker) 的权。通过下面这两个命令。

sudo usermod -aG docker user-name
sudo chown -R username /home/ubuntu/luopan-app

但是这样的话权限就会有点过于大了,因为 docker 本身就是一个很容易越权的服务。

所以更推荐第二种方式,就是只给这个用户赋予deploy.sh的权限。

幸好我的项目是有单独的部署脚本,所以只需要用户有权利运行deploy.sh就可以完成部署。

deploy.sh的两种模式

deploy.sh默认会有接受 SHA 和不接受 SHA 的两种情况,在有 SHA 的时候会下载并部署指定 commit,而没有的时候会git fetch origin main → git reset --hard origin/main。这样的设计为的是方便两种部署方式,一种是 actions,另一种直接 ssh 连接部署。

于是我们的权限设计也可以照顾这样的两种模式(实际上不照顾也行,因为默认actions 是会自带 SHA 的,而常规情况也不会通过这个用户来登录服务器)。

权限赋予

我们可以通过这样的命令来赋予权限(必须带 SHA):

sudo tee /usr/local/sbin/luopan-ssh-gateway >/dev/null <<'EOF'
#!/usr/bin/env bash

set -Eeuo pipefail

readonly DEPLOY_SCRIPT="/home/ubuntu/luopan-app/ops/deploy.sh"
readonly RECEIVED_COMMAND="${SSH_ORIGINAL_COMMAND:-}"

if [[ "${RECEIVED_COMMAND}" =~ ^deploy\ ([0-9a-f]{40})$ ]]; then
    readonly DEPLOY_SHA="${BASH_REMATCH[1]}"
    exec sudo -n -u ubuntu -- "${DEPLOY_SCRIPT}" "${DEPLOY_SHA}"
fi

echo 'This SSH key only permits:' >&2
echo '  deploy <40-character lowercase Git SHA>' >&2
exit 126
EOF

sudo chown root:root /usr/local/sbin/luopan-ssh-gateway
sudo chmod 755 /usr/local/sbin/luopan-ssh-gateway

这段代码的意思是在/usr/local/sbin 文件夹下创建luopan-ssh-gateway这样的网关脚本

set -Eeuo pipefail

这句表明使用严格模式,有任何错误都会尽快退出。

readonly DEPLOY_SCRIPT="/home/ubuntu/luopan-app/ops/deploy.sh"
readonly RECEIVED_COMMAND="${SSH_ORIGINAL_COMMAND:-}"

这两句则是定义了DEPLOY_SCRIPTRECEIVED_COMMAND ,前面的readonly 表明定义完之后不可变。

第二句中的SSH_ORIGINAL_COMMAND 就是在运行 openssh 的时候如果命令被截断(我会在下面定义 authorized_keys来截断),原本要执行的 command 都会注入到这个变量里面。

{变量:-}表示:

  • 如果变量存在,使用它的值;
  • 如果变量不存在或为空,使用空字符串。

比如有人尝试直接打开交互式 Shell:

ssh luopan-deploy@服务器

此时没有提交具体命令,RECEIVED_COMMAND 就会是空字符串,随后被拒绝。

if [[ "${RECEIVED_COMMAND}" =~ ^deploy\ ([0-9a-f]{40})$ ]]; then
    readonly DEPLOY_SHA="${BASH_REMATCH[1]}"
    exec sudo -n -u ubuntu -- "${DEPLOY_SCRIPT}" "${DEPLOY_SHA}"
fi

这一步则是正则匹配,并把匹配到的 SHA 存到DEPLOY_SHA ,然后则以 ubuntu(默认用户)来运行这个deploy.sh。这里就是提权的核心。

echo 'This SSH key only permits:' >&2
echo '  deploy <40-character lowercase Git SHA>' >&2

最后这两句就是拒绝非法指令。

sudo chown root:root /usr/local/sbin/luopan-ssh-gateway
sudo chmod 755 /usr/local/sbin/luopan-ssh-gateway

这两句大致是一个意思,就是让普通用户有权读取这个脚本,但是没权利修改。避免被用户不小心改掉。

授权执行部署脚本

上面提到的最后一步就是sudo -n -u ubuntu – "${DEPLOY_SCRIPT}" "${DEPLOY_SHA},而默认一个用户是无权以另一个用户的身份来执行任务的。

sudo visudo -f /etc/sudoers.d/luopan-deploy

写入以下内容:

Cmnd_Alias LUOPAN_DEPLOY_COMMANDS = /home/ubuntu/luopan-app/ops/deploy.sh, /home/ubuntu/luopan-app/ops/deploy.sh *

luopan-deploy ALL=(ubuntu) NOPASSWD: LUOPAN_DEPLOY_COMMANDS

赋权

sudo visudo -cf /etc/sudoers.d/luopan-deploy
sudo chmod 440 /etc/sudoers.d/luopan-deploy
sudo -l -U luopan-deploy

服务器authorized_keys添加。

创建.ssh 目录

sudo install \
  -d \
  -m 700 \
  -o luopan-deploy \
  -g luopan-deploy \
  /home/luopan-deploy/.ssh

700权限就是rwx------ ,也就是只有当前用户有权限。-o-g就是设定所有者和所属组,后面都是跟用户名。

最后得到的是这个:

drwx------ 2 luopan-deploy luopan-deploy ... /home/luopan-deploy/.ssh

生成受限制的authorized_keys

{
  printf 'restrict,command="/usr/local/sbin/luopan-ssh-gateway" '
  cat /tmp/luopan_github_actions.pub
} | sudo tee /home/luopan-deploy/.ssh/authorized_keys >/dev/null

这一段会创建以下文件

/home/luopan-deploy/.ssh/authorized_keys

文件内容是:

restrict,command="/usr/local/sbin/luopan-ssh-gateway" ssh-ed25519 AAAA-----------------AAAA... github-actions-luopanhacker

这里表明让用户登录之后,强制运行luopan-ssh-gateway

添加权限

sudo chown -R luopan-deploy:luopan-deploy /home/luopan-deploy/.ssh
sudo chmod 700 /home/luopan-deploy/.ssh
sudo chmod 600 /home/luopan-deploy/.ssh/authorized_keys

本地连接测试

ssh \
  -i ~/.ssh/luopan_github_actions \
  -o IdentitiesOnly=yes \
  luopan-deploy@服务器IP \
  "whoami"

应该就在返回:

This SSH key only permits:
  deploy
  deploy <40-character lowercase Git SHA>

腾讯云添加白名单

image.png

在白名单管理中添加指定用户的放行规则

image.png

总结

按照上面的步骤走完,就不会再提示报警信息了,而且同时安全性也能相当高。

其实今天我才真正理解了 linux 不同用户的设计是为什么了,之前我常常都是只使用一个用户或者 root,但是为了安全可靠,多用户设计是相当先进的。🥹

评论

注册或登录后即可评论。

评论将在接近此处时加载。