Files
volker.raschekandCopilot d7ab7e34d1 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 <copilot@github.com>
2026-10-18 14:46:48 +02:00

189 lines
5.7 KiB
YAML

---
- 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 }}"