diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml new file mode 100644 index 0000000..5cfda12 --- /dev/null +++ b/molecule/default/converge.yml @@ -0,0 +1,19 @@ +--- + +- name: Converge + hosts: all + # Passphrases are fixtures, they exercise the protected code paths of the role. + vars: + certificate_authority_root_ca_common_name: "Molecule Root CA" + certificate_authority_root_ca_tls_key_passphrase: "molecule-root-ca" + certificate_authority_intermediate_ca_common_name: "Molecule Intermediate CA" + certificate_authority_intermediate_ca_tls_key_passphrase: "molecule-intermediate-ca" + certificate_authority_client_skip: false + certificate_authority_client_common_name: "molecule.example.local" + certificate_authority_client_subject_alternative_names: + - "DNS:molecule.example.local" + - "IP:10.11.12.13" + tasks: + - name: Include the role certificate_authority + ansible.builtin.include_role: + name: certificate_authority diff --git a/molecule/default/create.yml b/molecule/default/create.yml new file mode 100644 index 0000000..60a8e84 --- /dev/null +++ b/molecule/default/create.yml @@ -0,0 +1,25 @@ +--- + +- name: Create + hosts: localhost + gather_facts: false + tasks: + - name: Start a container per platform + containers.podman.podman_container: + name: "{{ item.name }}" + image: "{{ item.image }}" + command: "sleep infinity" + state: started + loop: "{{ molecule_yml.platforms }}" + loop_control: + label: "{{ item.name }}" + + - name: Write the instance config + ansible.builtin.copy: + content: | + {% for platform in molecule_yml.platforms %} + - instance: {{ platform.name }} + connection: containers.podman.podman + {% endfor %} + dest: "{{ molecule_instance_config }}" + mode: "0600" diff --git a/molecule/default/destroy.yml b/molecule/default/destroy.yml new file mode 100644 index 0000000..8149947 --- /dev/null +++ b/molecule/default/destroy.yml @@ -0,0 +1,19 @@ +--- + +- name: Destroy + hosts: localhost + gather_facts: false + tasks: + - name: Remove the container of every platform + containers.podman.podman_container: + name: "{{ item.name }}" + state: absent + loop: "{{ molecule_yml.platforms }}" + loop_control: + label: "{{ item.name }}" + + - name: Empty the instance config + ansible.builtin.copy: + content: "[]" + dest: "{{ molecule_instance_config }}" + mode: "0600" diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml new file mode 100644 index 0000000..cd79f04 --- /dev/null +++ b/molecule/default/molecule.yml @@ -0,0 +1,24 @@ +--- + +driver: + name: default + options: + managed: true + login_cmd_template: "podman exec --interactive --tty {instance} bash" + +platforms: +- name: certificate-authority-archlinux + image: docker.io/library/archlinux:base +- name: certificate-authority-debian + image: docker.io/library/debian:13 +- name: certificate-authority-fedora + image: registry.fedoraproject.org/fedora:43 + +provisioner: + name: ansible + # The role under test is the project directory itself, so its parent has to be on the roles path. + env: + ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/.." + config_options: + defaults: + interpreter_python: auto_silent diff --git a/molecule/default/prepare.yml b/molecule/default/prepare.yml new file mode 100644 index 0000000..a3ddda3 --- /dev/null +++ b/molecule/default/prepare.yml @@ -0,0 +1,22 @@ +--- + +- name: Prepare + hosts: all + gather_facts: false + vars: + # The base images ship neither a python interpreter for ansible nor the tools the role shells out to. + _bootstrap: | + set -eu + if command -v pacman > /dev/null; then + pacman --sync --refresh --noconfirm ca-certificates gawk openssl python + elif command -v apt-get > /dev/null; then + apt-get update + apt-get install --yes ca-certificates gawk openssl python3 + else + dnf install --assumeyes ca-certificates gawk openssl python3 + fi + tasks: + # The podman connection plugin splits raw commands instead of passing them to a shell. + - name: Bootstrap the python interpreter and the tools required by the role + ansible.builtin.raw: "/bin/sh -c {{ _bootstrap | quote }}" + changed_when: true diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml new file mode 100644 index 0000000..9a53958 --- /dev/null +++ b/molecule/default/verify.yml @@ -0,0 +1,88 @@ +--- + +- name: Verify + hosts: all + vars: + _root_ca_path: "/etc/ansible-playbook/pki/ca" + _intermediate_ca_path: "/etc/ansible-playbook/pki/intermediate" + _client_path: "/etc/ansible-playbook/pki/client" + # cert.pem and cert-req.pem are left out on purpose, the role does not pin their mode. + _expected_modes: + /etc/ansible-playbook/pki/ca: "0755" + /etc/ansible-playbook/pki/ca/privkey.pem: "0600" + /etc/ansible-playbook/pki/ca/all.pem: "0600" + /etc/ansible-playbook/pki/intermediate: "0755" + /etc/ansible-playbook/pki/intermediate/privkey.pem: "0600" + /etc/ansible-playbook/pki/intermediate/chain.pem: "0644" + /etc/ansible-playbook/pki/intermediate/fullchain.pem: "0644" + /etc/ansible-playbook/pki/intermediate/all.pem: "0600" + /etc/ansible-playbook/pki/client: "0755" + /etc/ansible-playbook/pki/client/privkey.pem: "0600" + /etc/ansible-playbook/pki/client/chain.pem: "0644" + /etc/ansible-playbook/pki/client/fullchain.pem: "0644" + /etc/ansible-playbook/pki/client/all.pem: "0600" + _trust_store_anchor: + Archlinux: "/etc/ca-certificates/trust-source/anchors/Molecule_Root_CA.pem" + Debian: "/usr/local/share/ca-certificates/Molecule_Root_CA.crt" + RedHat: "/etc/pki/ca-trust/source/anchors/Molecule_Root_CA.pem" + tasks: + - name: Stat the generated files + ansible.builtin.stat: + path: "{{ item.key }}" + register: _pki_files + loop: "{{ _expected_modes | dict2items }}" + loop_control: + label: "{{ item.key }}" + + - name: Assert that the generated files exist with the expected mode + ansible.builtin.assert: + that: + - item.stat.exists + - item.stat.mode == item.item.value + fail_msg: "{{ item.item.key }} has mode {{ item.stat.mode | default('none') }} instead of {{ item.item.value }}" + loop: "{{ _pki_files.results }}" + loop_control: + label: "{{ item.item.key }}" + + - name: Verify the client certificate against the root certificate authority + ansible.builtin.command: + cmd: >- + openssl verify + -CAfile {{ _root_ca_path }}/cert.pem + -untrusted {{ _intermediate_ca_path }}/cert.pem + {{ _client_path }}/cert.pem + changed_when: false + + - name: Read the fullchain file of the client + ansible.builtin.slurp: + src: "{{ _client_path }}/fullchain.pem" + register: _client_fullchain + + - name: Assert that the fullchain of the client holds the complete chain and ends with a newline + vars: + _content: "{{ _client_fullchain.content | b64decode }}" + ansible.builtin.assert: + that: + - _content | regex_findall('BEGIN CERTIFICATE') | length == 3 + - _content.endswith('\n') + fail_msg: "unexpected content in {{ _client_path }}/fullchain.pem" + + - name: Read the subject alternative names of the client certificate + community.crypto.x509_certificate_info: + path: "{{ _client_path }}/cert.pem" + register: _client_cert_info + + - name: Assert that the requested subject alternative names are present + ansible.builtin.assert: + that: _client_cert_info.subject_alt_name | sort == ['DNS:molecule.example.local', 'IP:10.11.12.13'] + fail_msg: "unexpected subject alternative names {{ _client_cert_info.subject_alt_name }}" + + - name: Stat the anchor in the systems trust store + ansible.builtin.stat: + path: "{{ _trust_store_anchor[ansible_facts['os_family']] }}" + register: _anchor + + - name: Assert that the root certificate authority was imported into the systems trust store + ansible.builtin.assert: + that: _anchor.stat.exists + fail_msg: "{{ _trust_store_anchor[ansible_facts['os_family']] }} is missing"