使用 SOPS 保护 Ansible 密钥
CNCF SOPS 允许使用各种密钥源(GPG、AWS KMS、GCP KMS 等)加密和解密文件。对于结构化数据,例如 YAML、JSON、INI 和 ENV 文件,它会加密值,但不会加密映射键。对于 YAML 文件,它还会加密注释。这使其成为使用 Ansible 加密凭证的绝佳工具:您可以轻松查看哪些文件包含哪些变量,但变量本身是加密的。
与 Ansible Vault 相比,利用各种密钥源的能力使其在复杂的环境中使用起来更容易。
安装 SOPS
您可以在项目的发布页面上找到二进制文件和软件包。根据您的操作系统,您也可以使用系统的软件包管理器安装它。
此集合提供了一个 角色 community.sops.install,该角色允许安装 SOPS 和 GNU Privacy Guard (GPG)。该角色允许从系统的软件包管理器或 GitHub 安装 SOPS。SOPS 和 GPG 都可以安装在远程主机或 Ansible 控制器上。
- name: Playbook to install SOPS
hosts: all
tasks:
# To use the sops_encrypt module on a remote host, you need to install SOPS on it:
- name: Install SOPS on remote hosts
ansible.builtin.include_role:
name: community.sops.install
vars:
sops_version: 2.7.0 # per default installs the latest version
# To use the lookup plugin, filter plugin, vars plugin, or the load_vars action,
# you need SOPS installed on localhost:
- name: Install SOPS on localhost
ansible.builtin.include_role:
name: community.sops.install
vars:
sops_install_on_localhost: true
当使用 ansible-core 2.11 或更高版本时,您还可以使用两个方便的 playbook
# Install SOPS on Ansible controller
$ ansible-playbook community.sops.install_localhost
# Install SOPS on remote servers
$ ansible-playbook community.sops.install --inventory /path/to/inventory
在执行环境中安装 community.sops
在构建包含 community.sops 的执行环境时,请注意,默认情况下不会自动安装 SOPS。这是由于执行环境的依赖关系规范系统的限制。如果您正在构建一个包含 community.sops 的执行环境,您应该确保其中安装了 SOPS。
确保这一点的最简单方法是使用 community.sops.install_localhost
playbook。在定义执行环境时,您可以向 execution-environment.yml
添加一个 RUN
附加构建步骤
---
version: 3
dependencies:
galaxy: requirements.yml
additional_build_steps:
append_final:
# Ensure that SOPS is installed in the EE, assuming the EE is for ansible-core 2.11 or newer
- RUN ansible-playbook -v community.sops.install_localhost
请注意,这仅在执行环境使用 ansible-core 2.11 或更高版本构建时才有效。当使用 Ansible 2.9 的执行环境时,您必须手动使用 community.sops.install 角色。另请注意,您需要确保 Ansible 2.9 使用正确的 Python 解释器才能使用其安装系统软件包;在下面的示例中,我们假设了一个基于 RHEL/CentOS 的执行环境基础镜像
---
version: 3
dependencies:
galaxy: requirements.yml
additional_build_steps:
append_final:
# Special step needed for Ansible 2.9 based EEs
- >-
RUN ansible localhost -m include_role -a name=community.sops.install
-e sops_install_on_localhost=true
-e ansible_python_interpreter=/usr/libexec/platform-python
完成此步骤后,您可以在执行环境中使用 community.sops 中的所有插件和模块(在 localhost
上)。
设置 SOPS
从现在开始,本指南假定您已安装 SOPS。
为简单起见,您可以使用 GPG 密钥。如果您没有密钥,或者不想使用您的密钥,您可以运行 gpg --quick-generate-key [email protected]
为用户 ID [email protected]
创建一个 GPG 密钥。您将需要打印在末尾的 40 位十六进制密钥 ID。第一步是在您正在工作的目录树中创建一个 .sops.yaml
文件
creation_rules:
- pgp: 'FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4'
在这里,FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4
是 40 位十六进制密钥 ID。有了此文件,您可以通过在放置 .sops.yaml
的目录或其子目录中运行以下命令来创建 SOPS 加密文件
$ sops test.sops.yaml
这将打开一个带有示例 YAML 文件的编辑器窗口。在其中输入以下内容
# This is a comment
hello: world
foo:
- bar
- baz
关闭编辑器后,SOPS 将创建包含加密内容的 test.sops.yaml
#ENC[AES256_GCM,data:r6Ok05DzzHBO4tonlz2t49CF,iv:Y0P39iXwaGYU9NG5oRC3NuaGVL40uruSze0CxbDTpTk=,tag:EzoG+X+BJAHbxE0asSyGlQ==,type:comment]
hello: ENC[AES256_GCM,data:onBZqWk=,iv:bwj4bwaeh3vpVDYqY2AnYo1thF955i5vbFpCC1DwJtM=,tag:4qbVzuHTaPrXm64r2Rqz1Q==,type:str]
foo:
- ENC[AES256_GCM,data:UsY8,iv:USv71rKfvbTF+3a5T2WO56wGVu609/0uigqkO0pa6U4=,tag:s8NdqLp+8OOQg4xDfE78oA==,type:str]
- ENC[AES256_GCM,data:Dhmo,iv:qWs5gN2SCXYq0EfGelZhODsdViKB9w2taQMhsqy0D2g=,tag:I+ZFvuxnsvQmywqz+a/M9w==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age: []
lastmodified: "2021-06-15T19:36:34Z"
mac: ENC[AES256_GCM,data:HAvLeOvt7xWI7B5TCeDEsL6sOSzGGeTbgBSJaZkwadmoAm3Ny4IZPF8JAbFaPPLmN8FJVAt4D61aIWa6Xwi3xMj1g6DmxFfgK6JFJqWqW122UlMhqZ/WuMWFV6yVxpTLDXgemndgGDJqUTUi14FMh/MzPDg4f6kFP64kA9fpLrY=,iv:LdhswnMymZG8J9na/jnF3WYnX0DvzvoBlvjUCu4nI6c=,tag:Qt4d7L3FXsgfmg9iOs8P4A==,type:str]
pgp:
- created_at: "2021-06-15T19:36:01Z"
enc: |-
-----BEGIN PGP MESSAGE-----
wcBMAyUpShfNkFB/AQgAT8OAKnWLBQRG3kT5lZCmyoPzK6RwF0zRkwCzJkLNl6xg
nQjUjpD03ZD4FtiRidspXEj7NvCLDghJ0UETtDjmrwsTeJ5YAK/JxouWmoNhVVdF
p0qOlj/THXIV+ypVaqrisZGZiTqeWjUNFuayknvjm3XduOOPZA1MIJ14pQxcgca4
NWmKwPwXTWEy3RJ0ZsnjjjYvKHjHyvbHdbDgARu8R1jEgdNPKPBRVpEY6RNeafXI
gFBVRfrhPKD6HmnmNvjHwUc/K+wOa1ciIYVrT4mPXoyBsFkyV0egh/QRf0JO8+X7
Ut/jEtCrl9BXJCNYGmC5EU3PPiFlAu1MRxlCiPNWltLmASn2w62wMpgih6f+OpI/
zyEOdz0qx80LEfhv3+jBbDfBwz4GqpAHUr0fCXDzeDiKfzlU6isagoIAhJfwX6oG
NeQ47ktk1XhPmgIwxxuvonG14iQoU2cA
=GoXQ
-----END PGP MESSAGE-----
fp: FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4
unencrypted_suffix: _unencrypted
version: 3.7.1
第一行包含加密内容。第二行包含 hello: world
的未加密键,以及加密的字符串值 world
。接下来的几行包含未加密的键 foo
以及加密的列表元素。
最后,sops
部分包含元数据,其中包括解密使用 GPG 密钥 ID FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4
的公钥加密的文件所需的私钥。如果您配置了多个 GPG 密钥,或者还有其他密钥源,您还可以在此处找到使用这些密钥加密的文件密钥。
使用加密文件
您可以使用 community.sops.sops lookup 插件 解密 SOPS 加密的文件,并使用 community.sops.sops_encrypt 模块 动态加密数据。当您在 Ansible playbook 中创建或更新密钥时,能够进行加密非常有用。
假设您有一个加密的私钥 keys/private_key.pem.sops
,它在被 SOPS 加密之前是 PEM 格式的。
$ openssl genrsa -out keys/private_key.pem 2048
$ sops --encrypt keys/private_key.pem > keys/private_key.pem.sops
$ wipe keys/private_key.pem
要在 playbook 中使用它,例如将其传递给 community.crypto.openssl_csr 模块 来创建证书签名请求 (CSR),您可以使用 community.sops.sops lookup 插件 加载它。
---
- name: Load SOPS-encrypted private key
hosts: localhost
gather_facts: false
tasks:
- name: Create CSR with encrypted private key
community.crypto.openssl_csr:
# The private key is provided with SOPS:
privatekey_content: "{{ lookup('community.sops.sops', 'keys/private_key.pem.sops') }}"
# Store the CSR on disk unencrypted:
path: ansible.com.csr
# This is going to be a CSR for ansible.com and www.ansible.com
subject_alt_name:
- DNS:ansible.com
- DNS:www.ansible.com
use_common_name_for_san: false
这将产生以下输出:
PLAY [Load SOPS-encrypted private key] ***************************************************************************
TASK [Create CSR with encrypted private key] *********************************************************************
ok: [localhost]
PLAY RECAP *******************************************************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
之后,您将获得一个用于加密私钥 keys/private_key.pem.sops
的 CSR ansible.com.csr
。
如果您想使用 Ansible 生成(或更新)加密的私钥,您可以使用 community.crypto.openssl_privatekey_pipe 模块 生成(或更新)私钥,并使用 community.sops.sops_encrypt 模块 将其以加密形式写入磁盘。
---
- name: Create SOPS-encrypted private key
hosts: localhost
gather_facts: false
tasks:
- block:
- name: Create private key
community.crypto.openssl_privatekey_pipe:
size: 2048
no_log: true # Always use this with openssl_privatekey_pipe!
register: private_key
- name: Write encrypted key to disk
community.sops.sops_encrypt:
path: keys/private_key.pem.sops
content_text: "{{ private_key.privatekey }}"
always:
- name: Wipe private key from Ansible's facts
# This is particularly important if the playbook doesn't end here!
set_fact:
private_key: ''
此 playbook 每次运行都会创建一个新密钥。如果您希望私钥创建是幂等的,则需要做更多的工作。
---
- name: Create SOPS-encrypted private key
hosts: localhost
gather_facts: false
tasks:
- block:
- name: Create private key
community.crypto.openssl_privatekey_pipe:
size: 2048
content: >-
{{ lookup(
'community.sops.sops',
'keys/private_key.pem.sops',
empty_on_not_exist=true
) }}
no_log: true # Always use this with openssl_privatekey_pipe!
register: private_key
- name: Write encrypted key to disk
community.sops.sops_encrypt:
path: keys/private_key.pem.sops
content_text: "{{ private_key.privatekey }}"
when: private_key is changed
always:
- name: Wipe private key from Ansible's facts
# This is particularly important if the playbook doesn't end here!
set_fact:
private_key: ''
当密钥尚不存在时,需要使用 empty_on_not_exist=true
标志,以避免在密钥不存在时查找失败。当此 playbook 运行两次时,输出将是:
PLAY [Create SOPS-encrypted private key] *************************************************************************
TASK [Create private key] ****************************************************************************************
ok: [localhost]
TASK [Write encrypted key to disk] *******************************************************************************
skipping: [localhost]
TASK [Wipe private key from Ansible's facts] *********************************************************************
ok: [localhost]
PLAY RECAP *******************************************************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
处理来自其他来源的加密数据
您可以使用 community.sops.decrypt Jinja2 过滤器 来解密任意数据。这可以是先前从文件中读取的数据、从操作返回的数据,或通过其他方式获取的数据。
例如,假设您想使用 ansible.builtin.uri 模块 解密从 HTTPS 服务器检索的文件。要使用 community.sops.sops lookup,您必须首先将其写入文件。使用过滤器,您可以直接解密它。
---
- name: Decrypt file fetched from URL
hosts: localhost
gather_facts: false
tasks:
- name: Fetch file from URL
ansible.builtin.uri:
url: https://raw.githubusercontent.com/getsops/sops/master/functional-tests/res/comments.enc.yaml
return_content: true
register: encrypted_content
- name: Show encrypted data
debug:
msg: "{{ encrypted_content.content | ansible.builtin.from_yaml }}"
- name: Decrypt data and decode decrypted YAML
set_fact:
decrypted_data: "{{ encrypted_content.content | community.sops.decrypt | ansible.builtin.from_yaml }}"
- name: Show decrypted data
debug:
msg: "{{ decrypted_data }}"
输出将是:
PLAY [Decrypt file fetched from URL] *****************************************************************************
TASK [Fetch file from URL] ***************************************************************************************
ok: [localhost]
TASK [Show encrypted data] ***************************************************************************************
ok: [localhost] => {
"msg": {
"dolor": "ENC[AES256_GCM,data:IgvT,iv:wtPNYbDTARFE810PH6ldOLzCDcAjkB/dzPsZjpgHcko=,tag:zwE8P+AwO1hrHkgF6pTbZw==,type:str]",
"lorem": "ENC[AES256_GCM,data:PhmSdTs=,iv:J5ugEWq6RfyNx+5zDXvcTdoQ18YYZkqesDED7LNzou4=,tag:0Qrom6J6aUnZMZzGz5XCxw==,type:str]",
"sops": {
"age": [],
"azure_kv": [],
"gcp_kms": [],
"hc_vault": [],
"kms": [],
"lastmodified": "2020-10-07T15:49:13Z",
"mac": "ENC[AES256_GCM,data:2dhyKdHYSynjXPwYrn9356wA7vRKw+T5qwBenI2vZrgthpQBOCQG4M6f7eeH3VLTxB4mN4CAchb25dsNRoGr6A38VruaSSAhPco3Rh4AlvKSvXuhgRnzZvNxE/bnHX1D4K5cdTb4FsJg/Ue1l7UcWrlrv1s3H3SwLHP/nf+suD0=,iv:6xBYURjjaQzlUOKOrs2NWOChiNFZVAGPJZQZ59MwX3o=,tag:uXD5VYme+c8eHcCc5TD2YA==,type:str]",
"pgp": [
{
"created_at": "2019-08-29T21:52:32Z",
"enc": "-----BEGIN PGP MESSAGE-----\n\nhQEMAyUpShfNkFB/AQgAlvpTj0NYqF4mQyIeM7wX2SHLb4U07/flpqDpp2W/30Pz\nAHA7sYrgP0l8BrjT2kwtgCN0cdfoIHJudezrNjANp2P5TbP2b9kYYNxpehzB9PFj\nFixnCS7Zp8WIt1yXr1TX+ANZoXLopVcRbMaQ5OdH7CN1pNQtMR+R3FR3X/IqKxiU\nDo1YLaooRJICUC8LJw2Tb4K+lYnTSqd/HalLGym++ivFvdDB1Ya1GhT1FswXidXK\nIRjsOVbxV0q5VeNOR0zxsheOvuHyCje16c7NXJtATJVWtTFABJB8u7CY5HhZSgq+\nrXJHyLHqVLzJ8E4WqHQkMNUlVcrqAz7glZ6xbAhfI9JeAYk5SuBOQOQ4yvASqH4K\nb0N3+/abluBY7YPqKuRZBiEtmcYlZ+zIHuOTP1rD/7L5VY8CwE5U8SFlEqwM7nQJ\n6/vtl6qngOFjwt34WrhZzUfLPB/wRV/m1Qv2kr0RNA==\n=Ykiw\n-----END PGP MESSAGE-----\n",
"fp": "FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4"
}
],
"unencrypted_suffix": "_unencrypted",
"version": "3.6.1"
}
}
}
TASK [Decrypt data] **********************************************************************************************
ok: [localhost]
TASK [Show decrypted data] ***************************************************************************************
ok: [localhost] => {
"msg": {
"dolor": "sit",
"lorem": "ipsum"
}
}
PLAY RECAP *******************************************************************************************************
localhost : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
请注意,如果您在变量中放置一个 Jinja2 表达式,它将在每次使用时进行评估。解密数据需要一定的时间。如果您需要多次使用表达式,最好先使用 ansible.bulitin.set_fact 将其评估后的形式存储为事实。如果解密的数据应该传递给一个角色,这可能很重要。
---
- name: Decrypt file fetched from URL
hosts: localhost
gather_facts: false
tasks:
- name: Fetch file from URL
ansible.builtin.uri:
url: https://raw.githubusercontent.com/getsops/sops/master/functional-tests/res/comments.enc.yaml
return_content: true
register: encrypted_content
# BAD: every time the role uses decrypted_data, the data will be decrypted!
- name: Call role with decrypted data
include_role:
name: myrole
vars:
role_parameter: "{{ encrypted_content.content | community.sops.decrypt | ansible.builtin.from_yaml }}"
# GOOD: the data is decrypted once before the role is called,
- name: Store decrypted data as fact
set_fact:
decrypted_data: "{{ encrypted_content.content | community.sops.decrypt | ansible.builtin.from_yaml }}"
- name: Call role with decrypted data
include_role:
name: myrole
vars:
role_parameter: "{{ decrypted_data }}"
处理加密变量
您可以使用 community.sops.sops vars 插件 加载加密变量,类似于 ansible.builtin.host_group_vars vars 插件。如果您需要动态加载变量,类似于 ansible.builtin.include_vars action,则可以使用 community.sops.load_vars action。
要使用 vars 插件,您需要在您的 Ansible 配置文件 (ansible.cfg
) 中启用它。
[defaults]
vars_plugins_enabled = host_group_vars,community.sops.sops
有关启用 vars 插件的更多详细信息,请参阅 VARIABLE_PLUGINS_ENABLED。然后,您可以将具有以下扩展名的文件放入 group_vars
和 host_vars
目录中。
.sops.yaml
.sops.yml
.sops.json
(可以使用 valid_extensions
调整扩展名列表。)vars 插件将解密它们,您可以透明地使用它们的未加密内容。
如果您需要动态加载加密变量,类似于内置的 ansible.builtin.include_vars action,则可以使用 community.sops.load_vars action action。请注意,它不是一个完美的替代品,因为内置的 action 依赖于 ansible-core 中的一些硬编码的特殊情况,这允许它实际将变量加载为变量(更准确地说:作为“不安全”的 Jinja2 表达式,这些表达式在使用时会自动进行评估)。其他 action 插件(例如 community.sops.load_vars)无法做到这一点,而必须将变量加载为事实。
如果您在加密的变量文件中使用 Jinja2 表达式,这主要相关。当 ansible.builtin.include_vars 加载一个带有表达式的变量文件时,这些表达式仅在需要评估定义它们的变量时才会进行评估(惰性评估)。由于 community.sops.load_vars 返回事实,它必须在加载时直接评估表达式。(为此,将其 expressions
选项设置为 evaluate-on-load
。)如果您想引用同一文件中的其他变量,这主要相关:这将不起作用,因为 Ansible 在评估第一个变量时还不知道其他变量。只有在所有变量都被评估并且 action 完成后,它才会将它们“知道”为事实。
对于以下示例,假设您有加密的文件 keys/credentials.sops.yml
,它解密为:
encrypted_password: foo
expression: "{{ inventory_hostname }}"
考虑以下 playbook:
---
- name: Create SOPS-encrypted private key
hosts: localhost
gather_facts: false
tasks:
- name: Load encrypted credentials
community.sops.load_vars:
file: keys/credentials.sops.yml
expressions: ignore # explicitly do not evaluate expressions
# on load (this is the default)
- name: Show password
debug:
msg: "The password is {{ encrypted_password }}"
- name: Show expression
debug:
msg: "The expression is {{ expression }}"
运行它会产生:
PLAY [Create SOPS-encrypted private key] *************************************************************************
TASK [Load encrypted credentials] ********************************************************************************
ok: [localhost]
TASK [Show password] *********************************************************************************************
ok: [localhost] => {
"msg": "The password is foo"
}
TASK [Show expression] *******************************************************************************************
ok: [localhost] => {
"msg": "The expression is {{ inventory_hostname }}"
}
PLAY RECAP *******************************************************************************************************
localhost : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
如果您将变量加载任务更改为:
- name: Load encrypted credentials
community.sops.load_vars:
file: keys/credentials.sops.yml
expressions: evaluate-on-load
最后一个任务现在将显示已评估的表达式:
TASK [Show expression] *******************************************************************************************
ok: [localhost] => {
"msg": "The expression is localhost"
}