From d7ab7e34d1b3f5a31518453d5bb89ce40df54ce6 Mon Sep 17 00:00:00 2001 From: Markus Pesch Date: Sun, 18 Oct 2026 14:46:48 +0200 Subject: [PATCH] test: cover root configuration with Molecule Verify SSH key rotation, file permissions, optional file removal and password management on Arch Linux, Debian and Fedora. Keep container dependencies scoped to the test scenario. Co-authored-by: Copilot --- molecule/default/collections.yml | 4 + molecule/default/converge.yml | 11 ++ molecule/default/create.yml | 41 +++++++ molecule/default/destroy.yml | 28 +++++ molecule/default/molecule.yml | 23 ++++ molecule/default/prepare.yml | 19 +++ molecule/default/requirements.yml | 3 + molecule/default/vars/main.yml | 14 +++ molecule/default/verify.yml | 188 ++++++++++++++++++++++++++++++ requirements.yml | 3 + 10 files changed, 334 insertions(+) create mode 100644 molecule/default/collections.yml create mode 100644 molecule/default/converge.yml create mode 100644 molecule/default/create.yml create mode 100644 molecule/default/destroy.yml create mode 100644 molecule/default/molecule.yml create mode 100644 molecule/default/prepare.yml create mode 100644 molecule/default/requirements.yml create mode 100644 molecule/default/vars/main.yml create mode 100644 molecule/default/verify.yml create mode 100644 requirements.yml 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: []