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>
This commit is contained in:
10 files changed
+334
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
collections:
|
||||||
|
- name: community.docker
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
roles: []
|
||||||
@@ -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
|
||||||
@@ -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 }}"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
collections: []
|
||||||
Reference in new issue
Block a user