使用 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_varshost_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"
}