6 Commits
Author SHA1 Message Date
volker.raschekandCopilot c7be4a7999 fix(ci): use renamed ansible-lint configuration
Ansible Linter / ansible-lint (push) Failing after 1m17s
Lint Markdown files / markdown-lint (push) Successful in 11s
Molecule / Molecule (push) Successful in 4m51s
Release Ansible Role / Release Ansible Role (push) Successful in 3m7s
Point the Gitea lint workflow at .ansible-lint.yml so it can load the renamed configuration.

Co-authored-by: Copilot <copilot@github.com>
2026-10-18 14:51:01 +02:00
volker.raschekandCopilot 7c1939b54a ci: run Molecule on pushes and pull requests
Ansible Linter / ansible-lint (push) Failing after 4m7s
Lint Markdown files / markdown-lint (push) Successful in 12s
Molecule / Molecule (push) Successful in 5m19s
Exercise the role across supported test platforms to catch regressions before merging.

Co-authored-by: Copilot <copilot@github.com>
2026-10-18 14:46:48 +02:00
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
volker.raschekandCopilot c2f50c4423 fix(ssh)!: refresh public keys after key rotation
Derive public keys on every run and update their files only when the content changes, so existing public keys cannot become stale.

BREAKING CHANGE: The role no longer generates a key pair automatically when root.ssh.private_keys is empty or omitted. Provide private keys explicitly when needed.

Co-authored-by: Copilot <copilot@github.com>
2026-10-18 14:46:48 +02:00
volker.raschekandCopilot 44716aa722 chore: standardize YAML file extensions
Use .yml consistently across role metadata and tooling configuration.

Co-authored-by: Copilot <copilot@github.com>
2026-10-18 14:46:48 +02:00
volker.raschekandCopilot 7fa94793d9 chore: rename ansible-lint configuration
Use a YAML extension for explicit file type detection.

Co-authored-by: Copilot <copilot@github.com>
2026-10-18 14:44:27 +02:00
20 changed files with 380 additions and 29 deletions

No files matched your search

File renamed without changes.
@@ -16,5 +16,5 @@ jobs:
- name: Run ansible-lint - name: Run ansible-lint
uses: ansible/ansible-lint@v25.2.1 uses: ansible/ansible-lint@v25.2.1
with: with:
args: "--config-file .ansible-lint" args: "--config-file .ansible-lint.yml"
setup_python: "true" setup_python: "true"
+29
View File
@@ -0,0 +1,29 @@
name: Molecule
on:
pull_request:
types: [ "opened", "reopened", "synchronize" ]
push:
branches: [ '**' ]
tags-ignore: [ '**' ]
permissions:
contents: read
jobs:
molecule:
name: Molecule
runs-on: ubuntu-latest-amd64
steps:
# The scenario includes the role by its directory name, whose parent is used as roles path.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: root
- name: Install molecule
run: |
apt update --yes
apt install --yes python3-pip
pip3 install --break-system-packages molecule docker
- name: Run molecule
run: molecule test
working-directory: root
File renamed without changes.
File renamed without changes.
+2
View File
@@ -1,3 +1,5 @@
---
# #
# Documentation: # Documentation:
# https://yamllint.readthedocs.io/en/stable/ # https://yamllint.readthedocs.io/en/stable/
File renamed without changes.
File renamed without changes.
+4
View File
@@ -0,0 +1,4 @@
---
collections:
- name: community.docker
+11
View File
@@ -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
+41
View File
@@ -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
+28
View File
@@ -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"
+23
View File
@@ -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
+19
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
---
roles: []
+14
View File
@@ -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
+188
View File
@@ -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 }}"
+3
View File
@@ -0,0 +1,3 @@
---
collections: []
+14 -28
View File
@@ -52,27 +52,6 @@
root.ssh.authorized_keys is not defined or root.ssh.authorized_keys is not defined or
root.ssh is not defined root.ssh is not defined
- name: "Generate private and public SSH key"
when: root.ssh is defined and
(
root.ssh.private_keys is not defined or
root.ssh.private_keys | length <= 0
)
block:
- name: "Check if private SSH key already exists"
ansible.builtin.stat:
path: "/root/.ssh/{{ inventory_hostname_short }}.key"
register: stat_result
- name: "Generate private SSH key"
community.crypto.openssh_keypair:
path: "/root/.ssh/{{ inventory_hostname_short }}.key"
type: ed25519
size: 512
state: present
comment: "root@{{ inventory_hostname_short }}"
force: false
when: not stat_result.stat.exists
- name: "Create private and public SSH key" - name: "Create private and public SSH key"
when: root.ssh is defined and when: root.ssh is defined and
root.ssh.private_keys is defined and root.ssh.private_keys is defined and
@@ -90,18 +69,25 @@
label: "{{ item.filename }}" label: "{{ item.filename }}"
- name: "Extract public SSH keys from private keys" - name: "Extract public SSH keys from private keys"
ansible.builtin.shell: ansible.builtin.command:
args: argv: [ "ssh-keygen", "-y", "-f", "/root/.ssh/{{ item.filename }}" ]
executable: /bin/bash
cmd: "ssh-keygen -y -f {{ ('/root/.ssh/' + item.filename) | quote }} > {{ ('/root/.ssh/' + item.filename + '.pub') | quote }}"
creates: "/root/.ssh/{{ item.filename }}.pub"
register: _root_ssh_pub_key_extraction register: _root_ssh_pub_key_extraction
changed_when: _root_ssh_pub_key_extraction.rc == 0 changed_when: false
failed_when: _root_ssh_pub_key_extraction.rc > 0
loop: "{{ root.ssh.private_keys }}" loop: "{{ root.ssh.private_keys }}"
loop_control: loop_control:
label: "{{ item.filename }}" label: "{{ item.filename }}"
- name: "Write derived public SSH keys"
ansible.builtin.copy:
content: "{{ item.stdout }}\n"
dest: "/root/.ssh/{{ item.item.filename }}.pub"
owner: "root"
group: "root"
mode: "0644"
loop: "{{ _root_ssh_pub_key_extraction.results }}"
loop_control:
label: "{{ item.item.filename }}"
- name: "Create custom SSH client config" - name: "Create custom SSH client config"
ansible.builtin.template: ansible.builtin.template:
src: root/.ssh/config.j2 src: root/.ssh/config.j2