diff --git a/molecule/default/collections.yml b/molecule/default/collections.yml new file mode 100644 index 0000000..d43570a --- /dev/null +++ b/molecule/default/collections.yml @@ -0,0 +1,4 @@ +--- + +collections: +- name: community.docker diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml new file mode 100644 index 0000000..ee2c1db --- /dev/null +++ b/molecule/default/converge.yml @@ -0,0 +1,11 @@ +--- +- name: Converge + hosts: all + vars: + root: "{{ _root_configuration }}" + vars_files: + - vars/main.yml + tasks: + - name: Include the role root + ansible.builtin.include_role: + name: root diff --git a/molecule/default/create.yml b/molecule/default/create.yml new file mode 100644 index 0000000..4629e2f --- /dev/null +++ b/molecule/default/create.yml @@ -0,0 +1,41 @@ +--- +- name: Create + hosts: localhost + gather_facts: false + tasks: + - name: Start a container per platform + community.docker.docker_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: community.docker.docker + {% endfor %} + dest: "{{ molecule_instance_config }}" + mode: "0600" + + - name: Generate temporary SSH key pairs + ansible.builtin.command: + argv: + - ssh-keygen + - -t + - ed25519 + - -N + - "" + - -C + - molecule-root + - -f + - "{{ molecule_ephemeral_directory }}/{{ item }}" + creates: "{{ molecule_ephemeral_directory }}/{{ item }}" + loop: + - original.ed25519 + - replacement.ed25519 diff --git a/molecule/default/destroy.yml b/molecule/default/destroy.yml new file mode 100644 index 0000000..5d5c993 --- /dev/null +++ b/molecule/default/destroy.yml @@ -0,0 +1,28 @@ +--- +- name: Destroy + hosts: localhost + gather_facts: false + tasks: + - name: Remove the container of every platform + community.docker.docker_container: + name: "{{ item.name }}" + state: absent + loop: "{{ molecule_yml.platforms }}" + loop_control: + label: "{{ item.name }}" + + - name: Remove temporary SSH key pairs + ansible.builtin.file: + path: "{{ molecule_ephemeral_directory }}/{{ item }}" + state: absent + loop: + - original.ed25519 + - original.ed25519.pub + - replacement.ed25519 + - replacement.ed25519.pub + + - 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..5d13df3 --- /dev/null +++ b/molecule/default/molecule.yml @@ -0,0 +1,23 @@ +--- + +driver: + name: default + options: + managed: true + login_cmd_template: "docker exec --interactive --tty {instance} bash" + +platforms: +- name: root-archlinux + image: docker.io/library/archlinux:base +- name: root-debian + image: docker.io/library/debian:13 +- name: root-fedora + image: registry.fedoraproject.org/fedora:43 + +provisioner: + name: ansible + 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..a946fe4 --- /dev/null +++ b/molecule/default/prepare.yml @@ -0,0 +1,19 @@ +--- +- name: Prepare + hosts: all + gather_facts: false + vars: + _bootstrap: | + set -eu + if command -v pacman > /dev/null; then + pacman --sync --refresh --noconfirm openssh openssl python shadow + elif command -v apt-get > /dev/null; then + apt-get update + apt-get install --yes openssh-client openssl passwd python3 + else + dnf install --assumeyes openssh-clients openssl python3 shadow-utils + fi + tasks: + - name: Bootstrap Python and the tools required by the role + ansible.builtin.raw: "/bin/sh -c {{ _bootstrap | quote }}" + changed_when: true diff --git a/molecule/default/requirements.yml b/molecule/default/requirements.yml new file mode 100644 index 0000000..511b203 --- /dev/null +++ b/molecule/default/requirements.yml @@ -0,0 +1,3 @@ +--- + +roles: [] diff --git a/molecule/default/vars/main.yml b/molecule/default/vars/main.yml new file mode 100644 index 0000000..3409bcf --- /dev/null +++ b/molecule/default/vars/main.yml @@ -0,0 +1,14 @@ +--- + +_root_configuration: + ssh: + authorized_keys: + - content: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/original.ed25519.pub") }}' + command: /usr/bin/true + - content: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/replacement.ed25519.pub") }}' + config: + - Host: "*" + StrictHostKeyChecking: "no" + private_keys: + - content: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/original.ed25519", rstrip=false) }}' + filename: molecule.ed25519 diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml new file mode 100644 index 0000000..7fc134c --- /dev/null +++ b/molecule/default/verify.yml @@ -0,0 +1,188 @@ +--- +- name: Verify + hosts: all + vars: + _expected_modes: + /root/.ssh: "0700" + /root/.ssh/authorized_keys: "0600" + /root/.ssh/config: "0644" + /root/.ssh/molecule.ed25519: "0600" + /root/.ssh/molecule.ed25519.pub: "0644" + vars_files: + - vars/main.yml + tasks: + - name: Stat the SSH directory and files + ansible.builtin.stat: + path: "{{ item.key }}" + loop: "{{ _expected_modes | dict2items }}" + loop_control: + label: "{{ item.key }}" + register: _root_files + + - name: Assert SSH ownership and permissions + ansible.builtin.assert: + that: + - item.stat.exists + - item.stat.mode == item.item.value + - item.stat.pw_name == 'root' + - item.stat.gr_name == 'root' + loop: "{{ _root_files.results }}" + loop_control: + label: "{{ item.item.key }}" + + - name: Read the SSH files + ansible.builtin.slurp: + src: "{{ item }}" + loop: + - /root/.ssh/authorized_keys + - /root/.ssh/config + - /root/.ssh/molecule.ed25519 + - /root/.ssh/molecule.ed25519.pub + no_log: true + register: _root_contents + + - name: Assert SSH file contents + vars: + _original_public_key: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/original.ed25519.pub") }}' + _replacement_public_key: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/replacement.ed25519.pub") }}' + ansible.builtin.assert: + that: + - (('command="/usr/bin/true" ' + _original_public_key) in (_root_contents.results[0].content | b64decode).splitlines()) + - _replacement_public_key in (_root_contents.results[0].content | b64decode).splitlines() + - "'Host *' in (_root_contents.results[1].content | b64decode).splitlines()" + - "' StrictHostKeyChecking no' in (_root_contents.results[1].content | b64decode).splitlines()" + - (_root_contents.results[2].content | b64decode) == _root_configuration.ssh.private_keys[0].content + - (_root_contents.results[3].content | b64decode).split()[1] == _original_public_key.split()[1] + no_log: true + +- name: Verify SSH key rotation + hosts: all + vars: + root: + ssh: + authorized_keys: "{{ _root_configuration.ssh.authorized_keys }}" + config: "{{ _root_configuration.ssh.config }}" + private_keys: + - content: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/replacement.ed25519", rstrip=false) }}' + filename: molecule.ed25519 + vars_files: + - vars/main.yml + tasks: + - name: Replace the private key while keeping the public key file + ansible.builtin.include_role: + name: root + + - name: Read the public key after rotation + ansible.builtin.slurp: + src: /root/.ssh/molecule.ed25519.pub + register: _rotated_public_key + + - name: Assert the existing public key was updated + vars: + _expected_public_key: '{{ lookup("ansible.builtin.file", molecule_ephemeral_directory + "/replacement.ed25519.pub") }}' + ansible.builtin.assert: + that: + - (_rotated_public_key.content | b64decode).split()[1] == _expected_public_key.split()[1] + - _rotated_public_key.content != _root_contents.results[3].content + +- name: Verify root password and empty authorized keys + hosts: all + vars: + root: + password: molecule-root-password + ssh: + authorized_keys: [] + tasks: + - name: Set the root password and remove empty optional SSH files + ansible.builtin.include_role: + name: root + + - name: Read the root password entry + ansible.builtin.getent: + database: shadow + key: root + no_log: true + + - name: Hash the test password using the stored salt and rounds + ansible.builtin.command: + argv: + - openssl + - passwd + - -6 + - -salt + - "{{ ansible_facts.getent_shadow.root[0].split('$')[2:-1] | join('$') }}" + - -stdin + stdin: molecule-root-password + changed_when: false + no_log: true + register: _expected_password_hash + + - name: Assert the root password was set and unlocked + ansible.builtin.assert: + that: + - ansible_facts.getent_shadow.root[0].startswith('$6$') + - ansible_facts.getent_shadow.root[0] == _expected_password_hash.stdout + - ansible_become_pass == 'molecule-root-password' + no_log: true + + - name: Stat removed optional SSH files + ansible.builtin.stat: + path: "{{ item }}" + loop: + - /root/.ssh/authorized_keys + - /root/.ssh/config + register: _removed_files + + - name: Assert empty and undefined optional SSH files were removed + ansible.builtin.assert: + that: not item.stat.exists + loop: "{{ _removed_files.results }}" + loop_control: + label: "{{ item.item }}" + +- name: Restore SSH settings + hosts: all + vars: + root: "{{ _root_configuration }}" + vars_files: + - vars/main.yml + tasks: + - name: Restore optional SSH files before testing omitted SSH settings + ansible.builtin.include_role: + name: root + +- name: Verify root locking and omitted SSH settings + hosts: all + vars: + root: + password: "" + tasks: + - name: Lock root and remove omitted optional SSH files + ansible.builtin.include_role: + name: root + + - name: Read the locked root password entry + ansible.builtin.getent: + database: shadow + key: root + no_log: true + + - name: Assert root login is locked + ansible.builtin.assert: + that: ansible_facts.getent_shadow.root[0].startswith('!') + no_log: true + + - name: Stat optional SSH files after omitting SSH settings + ansible.builtin.stat: + path: "{{ item }}" + loop: + - /root/.ssh/authorized_keys + - /root/.ssh/config + register: _omitted_files + + - name: Assert omitted optional SSH files were removed + ansible.builtin.assert: + that: not item.stat.exists + loop: "{{ _omitted_files.results }}" + loop_control: + label: "{{ item.item }}" diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..2800d1e --- /dev/null +++ b/requirements.yml @@ -0,0 +1,3 @@ +--- + +collections: []