SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

Ansible Vault 如何加密 Git 中的密码和令牌

了解 Ansible Vault 的两种加密方式:加密整个 vars 文件或单个 YAML 值,并区分 staging 与 production,安全提交密码和 API 令牌。

Ansible Vault 保护什么,以及不保护什么

Ansible Vault 会加密 playbook 仓库中的机密,因此 git 存储的是密文,而不是明文密码。ansible-vault 命令可以使用您选择的密码派生出的对称密钥,加密整个文件或文件中的单个值。play 运行时,Ansible 会在内存中解密这些内容,因此该变量的行为与其他变量相同。

这种模型有明确的边界。Vault 只保护仓库中静态存储的机密,除此之外不提供其他保护。任务运行后,该值会以明文存在于内存、渲染后的模板和模块参数中;如果不加以阻止,还会出现在运行输出中。所有能够运行该 playbook 的人都持有 vault 密码,因此 vault 只能防止团队外部人员获取机密,不能提供团队内部的按人员访问控制。

如果您还没有编写 playbook,请先阅读针对 VPS 编写第一个 Ansible playbook;当该 playbook 需要密码时再返回这里。

加密整个文件,还是单个字符串?

ansible-vault encrypt 会将文件替换为密文。文件会变成一整块 Base64 文本,位于以 $ANSIBLE_VAULT 开头的标题行下方。文件内容全部是密钥等机密信息时,使用这种方式。

ansible-vault encrypt_string 会加密一个值,并输出一段 YAML 片段,供您粘贴到普通的 vars 文件中。变量名保持可读,只有变量值是密文。机密信息与明文配置并存时,使用这种方式。

日常工作中真正重要的区别在于差异对比。每次保存 vault 文件时,系统都会使用新的随机盐重新加密,因此密文的每个字节都会发生变化。此时,git diff 只会显示一个不可读的区块被另一个不可读的区块替换,审核者无法判断您是轮换了一个密码,还是重写了整个文件。使用 encrypt_string 时,每个机密值都是明文文件中的独立区块,因此差异对比会准确显示哪个变量发生了变化,文件的其余部分保持不变。

内联形式存在一个代价,密钥轮换时就会体现出来:ansible-vault rekey 不会处理内联区块。机密变量较多且很少变更时,选择文件形式。文件同时包含机密变量和普通变量,并且您希望代码审核能够准确反映变更时,选择内联形式。

展示受保护内容的 group_vars 布局

Ansible 会加载 group_vars/<group>.yml,也会加载 group_vars/<group>/ 目录中的每个文件。目录形式更适合此场景,因为这样同一个组可以并列存放明文文件和加密文件。

inventory/
  hosts.ini
group_vars/
  all/
    vars.yml
    vault.yml
  web/
    vars.yml
    vault.yml
host_vars/
  db01/
    vars.yml
    vault.yml
playbooks/
  site.yml

每个 vault.yml 都经过加密。每个 vars.yml 都是明文。读者无需打开文件即可知道哪些值受到保护,因为文件名已经说明了这一点。

这种模式的另一半是间接引用。在加密文件中,为每个变量添加 vault_ 前缀。

vault_db_password: "a real password"
vault_grafana_admin_token: "a real token"

然后在旁边的明文文件中引用这些名称。

db_password: "{{ vault_db_password }}"
grafana_admin_token: "{{ vault_grafana_admin_token }}"

角色和模板使用 db_password,无需了解值的来源,因此可以保持 playbook 与角色之间的职责分离。明文 vars.yml 还兼作可搜索的索引:grep -r vault_ group_vars/ 列出仓库所需的每个密钥,无需解密任何内容。代价是每个密钥多一个名称;如果 vault_ 名称拼写错误,程序会在运行时将其报告为未定义变量,而不是语法错误。

使用 encrypt_string 加密单个变量

ansible-vault encrypt_string --vault-id prod@~/.ansible/vault-prod.txt \
  --stdin-name 'vault_db_password'

输入机密内容,然后按 Ctrl-D。--stdin-name 从标准输入读取值,因此不会将其写入 shell 历史文件。另一种形式会将值放在命令行中,shell 会记录该值:

ansible-vault encrypt_string --vault-id prod@~/.ansible/vault-prod.txt \
  'a real password' --name 'vault_db_password'

无论使用哪种形式,该命令都会输出一个 YAML 块。请按输出内容原样将其粘贴到 vars 文件中,因为 !vault 标签下的缩进属于该值的一部分。

vault_db_password: !vault |
          $ANSIBLE_VAULT;1.2;AES256;prod
          6638643965323633646262656665306333616466396630323136393465356136396436383331
          3131303163306665326539353837343663313762616561306534373963383531613664393332

!vault 标签告知 YAML 加载器,该标量是密文而不是文本。标头包含格式版本、密码算法,以及用于加密该值的 vault ID 标签。不使用 vault ID 加密的值包含一个没有标签的 1.1 标头。这种值仍然可以正常使用,但无法说明密码的来源。

保险库密码存放在哪里?

存放在仓库外部。这是唯一没有例外的规则。

--ask-vault-pass 每次运行时提示一次,并且不存储任何内容。它适合在笔记本电脑上使用,但不适合 cron 任务或 CI runner。

密码文件是一个纯文本文件,第一行是密码。先以严格的权限创建空文件,再通过编辑器填入密码,这样密码不会进入 shell 历史记录:

mkdir -p ~/.ansible
install -m 600 /dev/null ~/.ansible/vault-prod.txt
$EDITOR ~/.ansible/vault-prod.txt

使用 --vault-password-file 将任意命令指向该文件:

ansible-playbook -i inventory/hosts.ini playbooks/site.yml \
  --vault-password-file ~/.ansible/vault-prod.txt

每次运行命令时重复指定该选项很容易忘记,因此请在仓库根目录的 ansible.cfg 中设置一次。

[defaults]
inventory = inventory/hosts.ini
vault_password_file = ~/.ansible/vault-prod.txt

相同的设置也会读取环境变量 ANSIBLE_VAULT_PASSWORD_FILE,CI 任务通常通过该变量提供密码。任务会从自身的凭据存储中读取密码,将其写入临时目录中的文件,导出该变量,并在运行结束时删除文件。同时也应将文件名模式添加到 .gitignore,因为 ansible.cfg 中的路径会被提交,而且迟早有人会在 checkout 目录中创建实际文件。

如果密码文件具有可执行权限,Ansible 会执行该文件,并从其标准输出读取密码,而不是将文件作为文本读取。这样可以从系统密钥环或云密钥管理器中获取保险库密码,完全不必将密码写入磁盘。通过 --vault-id 使用的脚本还有其他要求:其名称必须以 -client 结尾,或以 -client 加扩展名结尾;必须具有可执行权限;必须接受 --vault-id 选项;并且必须将密码输出到标准输出。

两个 Vault ID:staging 和 production

Vault ID 是附加到 Vault 密码上的标签,写作 label@source。其值可以是 prompt、密码文件的路径,或客户端脚本的路径。使用标签后,一个仓库可以使用多个密码保存机密,因此 staging 密码无法打开 production 文件。

ansible-vault encrypt --vault-id staging@~/.ansible/vault-staging.txt \
  group_vars/staging/vault.yml
ansible-vault encrypt --vault-id prod@~/.ansible/vault-prod.txt \
  group_vars/prod/vault.yml

传入一次运行可能需要的所有 ID:

ansible-playbook playbooks/site.yml \
  --vault-id staging@~/.ansible/vault-staging.txt \
  --vault-id prod@~/.ansible/vault-prod.txt

或者在 ansible.cfg 中统一列出:

[defaults]
vault_identity_list = staging@~/.ansible/vault-staging.txt, prod@~/.ansible/vault-prod.txt

有一种行为常常让人意外。默认情况下,标签只是提示,不是锁定条件。Ansible 会使用当前持有的每个机密依次尝试解密文件,直到其中一个成功。因此,标记为 staging 的文件仍可能被打开,只要 production 密码碰巧是正确的密钥。在 [defaults] 下设置 vault_id_match = True,或设置环境变量 ANSIBLE_VAULT_ID_MATCH 后,Ansible 只使用标签与文件头匹配的机密。此检查需要 1.2 文件头,因此只适用于最初使用 Vault ID 加密的内容。

加载多个 ID 后,ansible-vault encrypt 无法再确定应使用哪个密码加密。使用 --encrypt-vault-id prod 指定密码,或在 ansible.cfg 中设置 vault_encrypt_identity,为仓库指定默认值。

这样可以限制部署范围。部署 staging 的 CI 任务只获得 staging 密码,因此即使运行器遭到入侵,也无法读取 production 凭据。当您通过 一台控制机管理一组 Linux 服务器并运行 play 时,这种隔离可能决定事件影响较小,还是演变成大范围事件。

人员离职时重新设置保管库密钥

重新设置密钥会修改保管库密码,并使用新密码重新加密内容。它不会撤销任何访问权限。曾经持有旧密码的人,仍可解密其保留的仓库副本,包括该副本中的所有旧提交。因此,持有人离职后,应立即视为保管库密码已泄露,并按以下顺序轮换。

  1. 修改服务器和第三方服务中的实际凭据。这一步才会真正撤销访问权限。
  2. 使用 ansible-vault edit 将新值写入保管库文件。
  3. 使用新的保管库密码重新设置每个加密文件的密钥。
  4. 通过不经过仓库的通信渠道,将新的保管库密码交给仍需使用它的人员。
ansible-vault rekey --vault-id prod@~/.ansible/vault-prod-old.txt \
  --new-vault-id prod@prompt \
  group_vars/prod/vault.yml host_vars/db01/vault.yml

rekey 可在一条命令中接收多个文件,--new-vault-id prod@prompt 会提示输入一次新密码,而不是从磁盘读取密码。除非有充分理由,否则请保留相同的标签,因为该标签会写入命令重写的每个文件头部。

内联格式的代价就在这里。ansible-vault rekey 只处理完全加密的文件,因此,明文 vars 文件中嵌入的 !vault 块不会被修改。请先找出这些块,再使用新的密码通过 encrypt_string 重新生成每个块:

grep -rl '!vault' group_vars/ host_vars/

这就是完整的权衡。内联块便于查看差异,但每次轮换时都需要手动处理。完全加密的文件可以使用一条命令完成轮换,但在审查时无法提供有用的信息。

为什么机密仍会出现在输出中

值完成解密后,Vault 就不再提供保护。Ansible 会报告任务结果,而会回显参数的模块会将凭据带入该报告。详细运行、模板任务中的 --diff、转储参数的失败任务,或将输出写入文件的回调插件,都会保留明文。加密文件无法解决这些问题。

no_log: true 是控制开关。在接收凭据的任何任务上设置它。

- name: Write the application environment file
  ansible.builtin.template:
    src: app.env.j2
    dest: /etc/myapp/app.env
    owner: myapp
    group: myapp
    mode: "0600"
  no_log: true

随后,Ansible 会从输出中隐藏该任务的结果,因此日志只记录任务已运行,不记录任务处理的内容。尤其要在循环上设置它,因为循环会为每个项目报告一个结果,而遍历凭据列表的循环会报告整个列表。

还有另外4处可能泄露已解密的机密,no_log 都无法覆盖:

  • 从模板渲染的文件会继承您为其指定的 modeowner。对于任何包含凭据的文件,都应设置 mode: "0600" 和明确的所有者,否则该机密最终会在目标主机上对所有用户可读。
  • 传递给 ansible.builtin.commandansible.builtin.shell 的机密会在命令运行期间出现在目标主机的进程列表中,任何本地用户都可以读取。请改为通过文件或环境变量传递。
  • 事实缓存会将收集的事实写入控制机磁盘,因此,保存机密的注册变量可能会进入一个没人认为敏感的缓存文件。
  • 同一个机密通常还会存在于其他位置,例如容器读取的环境文件中。那里适用单独的规则,避免将凭据放入 Compose 环境文件对此进行了说明。

no_log 会增加调试难度,这正是它的用途。任务出现异常时,可在测试主机上暂时移除它;在变更进入生产环境前,务必恢复。

读取和编辑加密文件,同时不留下明文

ansible-vault view group_vars/prod/vault.yml 会将内容解密到分页器中,不会写入磁盘。ansible-vault edit 会将内容解密到临时文件,打开您的 $EDITOR,并在关闭时重新加密。两者都优先于 ansible-vault decrypt,后者会在工作树中留下明文文件。意外暂存已解密的 vault 文件,是实际凭据进入公共仓库的最常见方式。

Git 可以在处理过程中解密完全加密的文件,并生成可读的差异:

git config --local diff.ansible-vault.textconv "ansible-vault view --vault-password-file ~/.ansible/vault-prod.txt"
printf '%s\n' 'group_vars/**/vault.yml diff=ansible-vault' >> .gitattributes

启用前请先了解其作用。git diff 现在会将生产环境密钥打印到终端,因此这些内容会进入终端滚动缓冲区,也可能出现在屏幕共享中。这只是供单人在单台计算机上使用的本地便利功能,因此请将 git config 保持为本地设置;除非其他人也完成相同配置,否则他们的检出结果可能会有所不同。

何时不应再使用 vault

Vault 是一种按标签对应一个密码的文件格式,这种结构决定了它的适用边界。满足以下任一条件时,应改用真正的密钥存储系统。

  • 需要按人员授予访问权限。所有运行 playbook 的人员都持有同一个密码,而 vault ID 只能按环境划分访问权限,不能按人员划分。
  • 需要审计记录。Vault 不会记录谁在何时解密了哪些内容。
  • 需要按计划轮换凭据。Vault 不支持过期时间和版本管理,因此无法提示某个凭据已经两年没有变更。
  • 应用本身需要在运行时获取密钥。服务在启动时读取数据库密码时,不应从部署代码仓库读取该密码。

此时应反转原有模式。Ansible 不再存储密钥,而是通过 lookup 插件在运行时从 HashiCorp Vault(这是另一个产品,名称很容易混淆)、云服务商的密钥管理器,或控制机上的密钥环中获取密钥。代码仓库保存路径,存储系统保存值,存储系统保留访问日志。对于小型团队,带 API 的自托管密码管理器(例如 Vaultwarden 服务器)也能完成相同工作,但规模更小。

有一项凭据不属于上述范围。控制机用于连接服务器的 SSH 密钥不是 vault 要解决的问题,因为 Ansible 在运行任何 play 之前就需要它。应使用 agent 和密码短语管理该密钥,具体可参考 SSH 密钥管理基础

FAQ

是否应该加密整个 vars 文件,还是只加密机密字符串?

如果文件只包含机密信息,请加密整个文件。这样一次命令即可轮换全部内容,文件结构也更简单。如果机密信息与普通变量并列,请使用 ansible-vault encrypt_string。这样差异中只会显示加密值发生变化,审阅者也能看到修改了哪个变量。需要权衡的是轮换方式。ansible-vault rekey处理整个文件,不会修改内嵌的 !vault 块,因此必须使用新密码手动重新生成这些块。

Ansible Vault 密码文件应存储在哪里?

将其存储在仓库之外,并设置为模式 0600,例如放在 ~/.ansible/vault-prod.txt。使用 --vault-password-file 指定该文件,或在 ansible.cfg[defaults] 下设置 vault_password_file,也可以在环境中设置 ANSIBLE_VAULT_PASSWORD_FILE。在 CI 中,让任务从自身的凭据存储获取密码并写入临时文件,导出该变量,并在任务结束时删除文件。如果该文件具有可执行权限,Ansible 会运行它并从标准输出读取密码。这样即可从密钥环获取密码,而不必将其存储在磁盘上。

如何为预发布环境和生产环境使用不同的 Vault 密码?

使用 --vault-id staging@/path/to/file--vault-id prod@/path/to/file 为每个密码指定标签,并使用各自的标签加密每个环境的文件。在运行时传入两个 ID,或在 [defaults] 下的 vault_identity_list 中列出它们。默认情况下,Ansible 会依次尝试其持有的每个机密,直到成功解密文件。因此,如果希望它只尝试标签与文件头匹配的机密,请设置 vault_id_match = True。加载多个 ID 后,使用 --encrypt-vault-id 选择用于加密的 ID。

Ansible Vault 能否阻止密码出现在运行输出中?

不能。Vault 只保护仓库中静态存储的机密。任务运行后,该值就是明文;详细运行输出或失败的任务都可能将其写入日志。为每个处理凭据的任务添加 no_log: true;为模板生成的文件设置严格的 modeowner;并避免将机密作为命令参数传递,因为命令运行期间,目标主机的进程列表会显示这些参数。