diff --git a/.ostree/packages-runtime.txt b/.ostree/packages-runtime.txt index 553f3b7e..fcd3f389 100644 --- a/.ostree/packages-runtime.txt +++ b/.ostree/packages-runtime.txt @@ -1 +1,2 @@ +ca-certificates certmonger diff --git a/README.md b/README.md index df51fe51..09d3fc30 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ ansible-galaxy collection install -vv -r meta/collection-requirements.yml |-------------------------|----------------------------------------------------------------------------------------------------------------|:----:|:--------:|-------------------| | certificate_wait | If the task should wait for the certificate to be issued. | bool | no | yes | | certificate\_requests | A list of dicts representing each certificate to be issued. See [certificate_requests](#certificate_requests). | list | no | - | +| certificate\_trust | A list of dicts representing certificates to install into the system trust store. See [certificate_trust](#certificate_trust). | list | no | - | ### certificate_requests @@ -141,6 +142,35 @@ If `extended_key_usage` is not set the role will default to: * id-kp-serverAuth * id-kp-clientAuth +### certificate_trust + +Each item in the `certificate_trust` list describes a certificate to install +into (or remove from) the system trust store, so that TLS connections to +services using those certificates are trusted system-wide. + +| Parameter | Description | Type | Required | Default | +|-------------|-------------------------------------------------------------------------------------------------|:----:|:--------:|---------| +| name | Base file name of the trust anchor. The certificate is installed as `.crt` in the trust anchors directory. | str | yes | - | +| content | Inline PEM content of the certificate. | str | no | - | +| src | Path of a certificate file on the control node to copy to the trust anchors directory. | str | no | - | +| remote\_src | If `true`, `src` is a path on the managed host instead of the control node. | bool | no | no | +| url | URL to download the certificate from. | str | no | - | +| state | `present` to install the certificate, `absent` to remove it from the trust store. | str | no | present | + +Exactly one of `content`, `src`, or `url` must be given when `state` is +`present`. Installed files are owned by `root:root` with mode `0644`. When +any trust anchor is added or removed, the role updates the system trust +store by running the platform specific update command. + +The trust anchors directory and update command per platform: + +* RHEL/CentOS/Fedora: `/etc/pki/ca-trust/source/anchors/`, updated with + `update-ca-trust extract` +* SLES/openSUSE: `/etc/pki/trust/anchors/`, updated with + `update-ca-certificates` +* Debian/Ubuntu: `/usr/local/share/ca-certificates/`, updated with + `update-ca-certificates` + ### run hooks Sometimes you need to execute a command just before a certificate is @@ -163,6 +193,10 @@ CA represents the CA certificates that will be used to issue and sign the requested certificate. Provider represents the method used to send the certificate request to the CA and then retrieve the signed certificate. +The provider packages (for example `certmonger`) and services are only +installed and configured when `certificate_requests` is not empty. Using +only `certificate_trust` does not install any provider. + If a user chooses `self-sign` CA, with `certmonger` as provider and, later on decide to change the provider to `openssl`, the CA certificates used in both cases needs to be the same. *Please note that `openssl` is **not yet a supported** @@ -232,6 +266,43 @@ The example below creates a certificate file in - linux-system-roles.certificate ``` +### Adding certificates to the system trust store + +Install an internal CA certificate into the system trust store, one from +inline PEM content and one from a file on the control node. + +```yaml +--- +- hosts: webserver + vars: + certificate_trust: + - name: internal-ca + content: | + -----BEGIN CERTIFICATE----- + MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG + ... + -----END CERTIFICATE----- + - name: partner-ca + src: files/partner-ca.crt + + roles: + - linux-system-roles.certificate +``` + +To remove a certificate from the trust store, use `state: absent`: + +```yaml +--- +- hosts: webserver + vars: + certificate_trust: + - name: partner-ca + state: absent + + roles: + - linux-system-roles.certificate +``` + ### Issuing certificates with multiple DNS, IP and Email ```yaml diff --git a/defaults/main.yml b/defaults/main.yml index 54b915d0..8a9227ce 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -5,3 +5,13 @@ certificate_requests: [] certificate_wait: true + +# A list of dicts describing certificates to install into the system +# trust store. Each item supports the following keys: +# name (required) - base file name of the trust anchor +# content - inline PEM content of the certificate +# src - path of a certificate file to copy to the trust store +# remote_src - if true, src is a path on the managed host +# url - URL to download the certificate from +# state - present (default) or absent to remove an anchor +certificate_trust: [] diff --git a/tasks/certificate_trust.yml b/tasks/certificate_trust.yml new file mode 100644 index 00000000..bf610b19 --- /dev/null +++ b/tasks/certificate_trust.yml @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: MIT +--- +- name: Validate certificate_trust items + assert: + that: + - item.name is defined + - item.state | d('present') in ['present', 'absent'] + - >- + item.state | d('present') == 'absent' or + [item.content is defined, item.src is defined, item.url is defined] | + select | list | length == 1 + fail_msg: >- + Each certificate_trust item must have a name, state must be 'present' + or 'absent', and exactly one of content, src, or url must be given + when state is 'present' + loop: "{{ certificate_trust }}" + loop_control: + label: "{{ item.name | d('unnamed') }}" + +- name: Ensure trust store packages are installed + package: + name: "{{ __certificate_trust_packages }}" + state: present + use: "{{ (__certificate_is_ostree | d(false)) | + ternary('ansible.posix.rhel_rpm_ostree', omit) }}" + +- name: Install trust certificates + copy: + content: "{{ item.content | default(omit) }}" + src: "{{ item.src | default(omit) }}" + remote_src: "{{ item.remote_src | default(omit) }}" + dest: "{{ __certificate_trust_dir }}/{{ item.name }}.crt" + owner: root + group: root + mode: "0644" + loop: "{{ certificate_trust }}" + loop_control: + label: "{{ item.name }}" + when: + - item.state | d('present') == 'present' + - item.content is defined or item.src is defined + register: __certificate_trust_copied + +- name: Install trust certificates from URL + get_url: + url: "{{ item.url }}" + dest: "{{ __certificate_trust_dir }}/{{ item.name }}.crt" + owner: root + group: root + mode: "0644" + loop: "{{ certificate_trust }}" + loop_control: + label: "{{ item.name }}" + when: + - item.state | d('present') == 'present' + - item.url is defined + register: __certificate_trust_downloaded + +- name: Remove trust certificates + file: + path: "{{ __certificate_trust_dir }}/{{ item.name }}.crt" + state: absent + loop: "{{ certificate_trust }}" + loop_control: + label: "{{ item.name }}" + when: item.state | d('present') == 'absent' + register: __certificate_trust_removed + +- name: Update system trust store + command: "{{ __certificate_update_trust_cli }}" + changed_when: true + when: __certificate_trust_copied is changed + or __certificate_trust_downloaded is changed + or __certificate_trust_removed is changed diff --git a/tasks/main.yml b/tasks/main.yml index 7fd47b19..7f63a9ce 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -2,18 +2,19 @@ - name: Set version specific variables include_tasks: tasks/set_vars.yml -- name: Ensure certificate role dependencies are installed - package: - name: "{{ __certificate_packages }}" - state: present - use: "{{ (__certificate_is_ostree | d(false)) | - ternary('ansible.posix.rhel_rpm_ostree', omit) }}" - -- name: Install and configure required providers +- name: Install and configure requirements and providers + when: certificate_requests | length > 0 vars: __certificate_providers: "{{ (certificate_requests | selectattr('provider', 'defined') | map(attribute='provider') | list) + [__certificate_provider_default] | unique | list }}" block: + - name: Ensure certificate role dependencies are installed + package: + name: "{{ __certificate_packages }}" + state: present + use: "{{ (__certificate_is_ostree | d(false)) | + ternary('ansible.posix.rhel_rpm_ostree', omit) }}" + - name: Ensure provider packages are installed package: name: "{{ __certificate_provider_vars[__certificate_provider].packages }}" @@ -86,6 +87,10 @@ loop_var: __certificate_provider when: __certificate_provider_vars[__certificate_provider].service is defined +- name: Ensure certificates in the system trust store + include_tasks: tasks/certificate_trust.yml + when: certificate_trust | length > 0 + - name: Ensure certificate requests certificate_request: name: "{{ item.name | default(omit) }}" diff --git a/tests/setup-snapshot.yml b/tests/setup-snapshot.yml index 80288464..73fb1c7f 100644 --- a/tests/setup-snapshot.yml +++ b/tests/setup-snapshot.yml @@ -10,5 +10,6 @@ - name: Install test packages package: - name: "{{ __certificate_packages }}" + name: "{{ __certificate_packages + __certificate_trust_packages + + ['openssl'] }}" state: present diff --git a/tests/tests_trust.yml b/tests/tests_trust.yml new file mode 100644 index 00000000..b351c254 --- /dev/null +++ b/tests/tests_trust.yml @@ -0,0 +1,172 @@ +# SPDX-License-Identifier: MIT +--- +- name: Test adding certificates to the system trust store + hosts: all + vars: + __trust_test_ca_cert: /tmp/lsr_trust_test_ca.crt + __trust_test_ca_key: /tmp/lsr_trust_test_ca.key + tasks: + - name: Run certificate_trust tests + block: + - name: Get var __certificate_is_ostree and other role vars + include_role: + name: linux-system-roles.certificate + tasks_from: set_vars.yml + public: true + + - name: Ensure openssl is installed + package: + name: openssl + state: present + use: "{{ (__certificate_is_ostree | d(false)) | + ternary('ansible.posix.rhel_rpm_ostree', omit) }}" + + - name: Generate a self-signed test CA certificate + command: >- + openssl req -x509 -new -newkey rsa:2048 -nodes -days 3650 + -subj /CN=LSR-Trust-Test-CA + -keyout {{ __trust_test_ca_key }} + -out {{ __trust_test_ca_cert }} + changed_when: true + + - name: Check that the test CA certificate is not yet trusted + command: openssl verify {{ __trust_test_ca_cert }} + register: __trust_verify_before + changed_when: false + failed_when: false + + - name: Assert that the test CA certificate is not yet trusted + assert: + that: "__trust_verify_before.stdout is not search(': OK')" + + - name: Run the role to trust a certificate file on the managed host + include_tasks: tasks/run_role_with_clear_facts.yml + vars: + __sr_public: true + certificate_trust: + - name: lsr-trust-test-src + src: "{{ __trust_test_ca_cert }}" + remote_src: true + + - name: Get the trust anchor file attributes + stat: + path: "{{ __certificate_trust_dir }}/lsr-trust-test-src.crt" + register: __trust_anchor + + - name: Assert that the trust anchor was installed + assert: + that: + - __trust_anchor.stat.exists + - __trust_anchor.stat.mode == '0644' + - __trust_anchor.stat.pw_name == 'root' + - __trust_anchor.stat.gr_name == 'root' + + - name: Check that the test CA certificate is now trusted + command: openssl verify {{ __trust_test_ca_cert }} + register: __trust_verify_after + changed_when: false + failed_when: "{{ __trust_verify_after.stdout is not search(': OK') }}" + + - name: Slurp the test CA certificate + slurp: + path: "{{ __trust_test_ca_cert }}" + register: __trust_test_ca_slurp + + - name: Run the role to trust inline certificate content + include_tasks: tasks/run_role_with_clear_facts.yml + vars: + __sr_public: true + certificate_trust: + - name: lsr-trust-test-content + content: "{{ __trust_test_ca_slurp.content | b64decode }}" + + - name: Get the trust anchor file attributes + stat: + path: "{{ __certificate_trust_dir }}/lsr-trust-test-content.crt" + register: __trust_anchor_content + + - name: Assert that the trust anchor was installed + assert: + that: + - __trust_anchor_content.stat.exists + - __trust_anchor_content.stat.mode == '0644' + - __trust_anchor_content.stat.pw_name == 'root' + - __trust_anchor_content.stat.gr_name == 'root' + + - name: Verify that conflicting certificate sources fail validation + block: + - name: Run the role with conflicting certificate sources + include_tasks: tasks/run_role_with_clear_facts.yml + vars: + certificate_trust: + - name: lsr-trust-test-invalid + content: not-a-certificate + url: https://ca.example.com/ca.crt + + - name: Failed + fail: + msg: Role did not fail with conflicting certificate sources + rescue: + - name: Assert that the role failed in validation + assert: + that: >- + ansible_failed_result | to_json is + search('exactly one of content, src, or url') + fail_msg: >- + The role failed for an unexpected reason: + {{ ansible_failed_result }} + + - name: Run the role to remove the trust anchors + include_tasks: tasks/run_role_with_clear_facts.yml + vars: + __sr_public: true + certificate_trust: + - name: lsr-trust-test-src + state: absent + - name: lsr-trust-test-content + state: absent + + - name: Get the trust anchor file attributes + stat: + path: "{{ __certificate_trust_dir }}/{{ item }}.crt" + loop: + - lsr-trust-test-src + - lsr-trust-test-content + register: __trust_anchors_removed + + - name: Assert that the trust anchors were removed + assert: + that: not item.stat.exists + loop: "{{ __trust_anchors_removed.results }}" + loop_control: + label: "{{ item.item }}" + + - name: Check that the test CA certificate is no longer trusted + command: openssl verify {{ __trust_test_ca_cert }} + register: __trust_verify_removed + changed_when: false + failed_when: false + + - name: Assert that the test CA certificate is no longer trusted + assert: + that: "__trust_verify_removed.stdout is not search(': OK')" + always: + - name: Run the role to clean up the trust anchors + include_tasks: tasks/run_role_with_clear_facts.yml + vars: + __sr_failed_when: false + certificate_trust: + - name: lsr-trust-test-src + state: absent + - name: lsr-trust-test-content + state: absent + tags: tests::cleanup + + - name: Clean up the test CA certificate and key + file: + path: "{{ item }}" + state: absent + loop: + - "{{ __trust_test_ca_cert }}" + - "{{ __trust_test_ca_key }}" + tags: tests::cleanup diff --git a/vars/Debian.yml b/vars/Debian.yml index 497bdd69..e4385964 100644 --- a/vars/Debian.yml +++ b/vars/Debian.yml @@ -3,3 +3,6 @@ # Put internal variables here with Debian 11 specific values. __certificate_default_directory: /etc/ssl + +__certificate_trust_dir: /usr/local/share/ca-certificates +__certificate_update_trust_cli: /usr/sbin/update-ca-certificates diff --git a/vars/Suse.yml b/vars/Suse.yml index ae72360a..c2fb3b9d 100644 --- a/vars/Suse.yml +++ b/vars/Suse.yml @@ -8,3 +8,6 @@ __certificate_packages: - python3-cryptography - python3-dbus-python - python3-pyasn1 + +__certificate_trust_dir: /etc/pki/trust/anchors +__certificate_update_trust_cli: /usr/sbin/update-ca-certificates diff --git a/vars/main.yml b/vars/main.yml index ae822aaf..d5df100f 100644 --- a/vars/main.yml +++ b/vars/main.yml @@ -23,6 +23,12 @@ __certificate_packages: __certificate_default_directory: /etc/pki/tls +# System trust store settings - RedHat family defaults +__certificate_trust_dir: /etc/pki/ca-trust/source/anchors +__certificate_update_trust_cli: /usr/bin/update-ca-trust extract +__certificate_trust_packages: + - ca-certificates + # ansible_facts required by the role __certificate_required_facts: - distribution