From de280ef620c6e9b77b97d3cd661b485c4c3a3fa6 Mon Sep 17 00:00:00 2001 From: Markus Pesch Date: Sat, 17 Oct 2026 21:41:02 +0200 Subject: [PATCH] feat(ssh)!: configure keys with filename and content Accept SSH key content directly so callers no longer need role-specific source directories. Update Molecule coverage and documentation. BREAKING CHANGE: private_keys entries must be objects with filename and content instead of file names. authorized_keys entries must provide content alongside filename. Public keys are also written to ~/.ssh. Co-authored-by: Copilot --- README.md | 15 +++++++++++---- defaults/main.yml | 35 ++++++++++++++++++++--------------- molecule/default/converge.yml | 8 ++++++-- molecule/default/verify.yml | 31 ++++++++++++++++++++++++++++--- tasks/create_unix_user.yml | 26 ++++++++++++++++++++------ templates/authorized_keys.j2 | 4 ++-- 6 files changed, 87 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 68353fe..20e8dbd 100644 --- a/README.md +++ b/README.md @@ -138,10 +138,11 @@ environment variables can be espcilitly defined, to consume it during execution > [!IMPORTANT] > To allow consuming environment variables must be set `PermitUserEnvironment yes` in `/etc/ssh/sshd_config`. -The private key `toor@toor-pc.ed25519.key` must be stored in `ssh/private_keys`. The public key will be automatically -extracted from the private key. +Private and public keys are configured directly using `filename` and `content`. The role writes each key to +`~/.ssh/`. Store private key content encrypted with Ansible Vault. -The public keys `claire@claire-pc.pub` as well as `bob@bob-pc.pub` must be stored in `ssh/authorized_keys`. +Entries in `authorized_keys` are also added to `~/.ssh/authorized_keys`, including their optional command and environment +settings. Public keys are still automatically extracted from private keys into `~/.ssh/.pub`. ```yaml unix_users: @@ -157,13 +158,19 @@ unix_users: UserKnownHostFile: /dev/null authorized_keys: - filename: claire@claire-pc.pub + content: ssh-ed25519 AAAAC3NzaC... claire - command: /usr/local/bin/upload-file.sh envs: - key: SSH_KEY_NAME value: bob@bob-pc filename: bob@bob-pc.pub + content: ssh-ed25519 AAAAC3NzaC... bob private_keys: - - toor@toor-pc.ed25519.key + - filename: toor@toor-pc.ed25519.key + content: | + -----BEGIN OPENSSH PRIVATE KEY----- + ... + -----END OPENSSH PRIVATE KEY----- shell: /bin/bash password: toor group: toor diff --git a/defaults/main.yml b/defaults/main.yml index 5f73970..430eeb0 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -8,21 +8,26 @@ unix_groups: {} # gid: 1001 # state: absent -unix_users: {} -# alice: -# state: "present" -# name: "Alice" -# uid: "1000" -# ssh: -# config: -# - Host: "*" -# StrictHostKeyChecking: "no" -# UserKnownHostFile: /dev/null -# authorized_keys: -# - filename: alice@alice-pc.pub -# # command: "" # optional -# private_keys: -# - alice@alice-pc.ed25519.key +unix_users: + alice: + state: "present" + name: "Alice" + uid: "1000" + ssh: + config: + - Host: "*" + StrictHostKeyChecking: "no" + UserKnownHostFile: /dev/null + authorized_keys: + - filename: alice@alice-pc.pub + content: ssh-ed25519 AAAAC3NzaC... alice + # command: "" # optional + private_keys: + - filename: alice@alice-pc.ed25519.key + content: | + -----BEGIN OPENSSH PRIVATE KEY----- + ... + -----END OPENSSH PRIVATE KEY----- # home: /home/alice # btrfs: false # netrc: diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 39bdc11..489dab3 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -28,13 +28,17 @@ - Host: "*" StrictHostKeyChecking: "no" authorized_keys: - - filename: molecule.pub + - filename: alice.pub + content: "{{ lookup('ansible.builtin.file', molecule_scenario_directory + '/files/ssh/authorized_keys/molecule.pub', rstrip=false) }}" command: "/usr/bin/true" envs: - key: EDITOR value: vi + - filename: alice-login.pub + content: "{{ lookup('ansible.builtin.file', molecule_scenario_directory + '/files/ssh/authorized_keys/molecule.pub', rstrip=false) }}" private_keys: - - molecule.ed25519.key + - filename: alice.ed25519.key + content: "{{ lookup('ansible.builtin.file', molecule_scenario_directory + '/files/ssh/private_keys/molecule.ed25519.key', rstrip=false) }}" netrc: - machine: hostname.local login: alice diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml index 4df9331..b49fd34 100644 --- a/molecule/default/verify.yml +++ b/molecule/default/verify.yml @@ -9,8 +9,10 @@ /home/molecule-alice/.ssh: "0700" /home/molecule-alice/.ssh/authorized_keys: "0600" /home/molecule-alice/.ssh/config: "0644" - /home/molecule-alice/.ssh/molecule.ed25519.key: "0600" - /home/molecule-alice/.ssh/molecule.ed25519.key.pub: "0644" + /home/molecule-alice/.ssh/alice.ed25519.key: "0600" + /home/molecule-alice/.ssh/alice.ed25519.key.pub: "0644" + /home/molecule-alice/.ssh/alice.pub: "0644" + /home/molecule-alice/.ssh/alice-login.pub: "0644" /home/molecule-alice/.forward: "0644" /home/molecule-alice/.netrc: "0600" /home/molecule-alice/.bashrc.d: "0755" @@ -72,7 +74,7 @@ register: _alice_keys loop: - "{{ _alice_home }}/.ssh/authorized_keys" - - "{{ _alice_home }}/.ssh/molecule.ed25519.key.pub" + - "{{ _alice_home }}/.ssh/alice.ed25519.key.pub" - name: Assert that the authorized key carries its options and matches the extracted public key vars: @@ -83,8 +85,31 @@ - _authorized_key.startswith('command="/usr/bin/true",environment="EDITOR=vi" ') - _public_key.startswith('ssh-ed25519 ') - _public_key.split()[1] == _authorized_key.split()[2] + - (_alice_keys.results[0].content | b64decode).splitlines() | select('match', '^ssh-ed25519 ') | list | length == 1 fail_msg: "the authorized key of molecule-alice does not match the key extracted from its private key" + - name: Read the configured private and public SSH key files + ansible.builtin.slurp: + src: "{{ _alice_home }}/.ssh/{{ item }}" + loop: + - alice.ed25519.key + - alice.pub + - alice-login.pub + no_log: true + register: _alice_key_contents + + - name: Assert that SSH key files contain the exact configured content + vars: + _private_key_content: "{{ lookup('ansible.builtin.file', molecule_scenario_directory + '/files/ssh/private_keys/molecule.ed25519.key', rstrip=false) }}" + _public_key_content: "{{ lookup('ansible.builtin.file', molecule_scenario_directory + '/files/ssh/authorized_keys/molecule.pub', rstrip=false) }}" + ansible.builtin.assert: + that: + - (_alice_key_contents.results[0].content | b64decode) == _private_key_content + - (_alice_key_contents.results[1].content | b64decode) == _public_key_content + - (_alice_key_contents.results[2].content | b64decode) == _public_key_content + - _public_key_content | trim in (_alice_keys.results[0].content | b64decode).splitlines() + no_log: true + - name: Read the user and group database ansible.builtin.getent: database: "{{ item }}" diff --git a/tasks/create_unix_user.yml b/tasks/create_unix_user.yml index cedc213..730ebdc 100644 --- a/tasks/create_unix_user.yml +++ b/tasks/create_unix_user.yml @@ -95,11 +95,21 @@ state: absent when: unix_user.value.ssh.authorized_keys is not defined or unix_user.value.ssh.authorized_keys | length <= 0 -# The relative source is resolved against the files directory of the playbook, like the lookup in authorized_keys.j2. +- name: "Create public SSH keys for unix user: {{ unix_user.key }}" + ansible.builtin.copy: + content: "{{ item.content }}" + dest: "{{ _unix_users_home }}/.ssh/{{ item.filename }}" + owner: "{{ unix_user.key }}" + group: "{{ unix_user.value.group | default('users') }}" + mode: "0644" + loop: "{{ unix_user.value.ssh.authorized_keys | default([]) }}" + loop_control: + label: "{{ item.filename }}" + - name: "Create private SSH keys for unix user: {{ unix_user.key }}" ansible.builtin.copy: - src: "ssh/private_keys/{{ item }}" - dest: "{{ _unix_users_home }}/.ssh/{{ item }}" + content: "{{ item.content }}" + dest: "{{ _unix_users_home }}/.ssh/{{ item.filename }}" owner: "{{ unix_user.key }}" group: "{{ unix_user.value.group | default('users') }}" mode: "0600" @@ -110,18 +120,22 @@ - name: "Extract public SSH keys from private keys for unix user: {{ unix_user.key }}" ansible.builtin.shell: - cmd: "ssh-keygen -y -f {{ _unix_users_home }}/.ssh/{{ item }} > {{ _unix_users_home }}/.ssh/{{ item }}.pub" - creates: "{{ _unix_users_home }}/.ssh/{{ item }}.pub" + cmd: "ssh-keygen -y -f {{ (_unix_users_home + '/.ssh/' + item.filename) | quote }} > {{ (_unix_users_home + '/.ssh/' + item.filename + '.pub') | quote }}" + creates: "{{ _unix_users_home }}/.ssh/{{ item.filename }}.pub" + loop_control: + label: "{{ item.filename }}" with_items: - "{{ unix_user.value.ssh.private_keys }}" when: unix_user.value.ssh.private_keys is defined and unix_user.value.ssh.private_keys | length > 0 - name: "Correct permissions of public SSH keys for unix user: {{ unix_user.key }}" ansible.builtin.file: - path: "{{ _unix_users_home }}/.ssh/{{ item }}.pub" + path: "{{ _unix_users_home }}/.ssh/{{ item.filename }}.pub" owner: "{{ unix_user.key }}" group: "{{ unix_user.value.group | default('users') }}" mode: "0644" + loop_control: + label: "{{ item.filename }}" with_items: - "{{ unix_user.value.ssh.private_keys }}" when: unix_user.value.ssh.private_keys is defined and unix_user.value.ssh.private_keys | length > 0 diff --git a/templates/authorized_keys.j2 b/templates/authorized_keys.j2 index c7c1c83..0ee128a 100644 --- a/templates/authorized_keys.j2 +++ b/templates/authorized_keys.j2 @@ -21,8 +21,8 @@ {% endif %} {% endif %} {% if _args | length > 0 %} -{{ _args | join(',') }} {{ lookup('file', 'ssh/authorized_keys/' + authorized_key.filename ) }} +{{ _args | join(',') }} {{ authorized_key.content | trim }} {% else %} -{{ lookup('file', 'ssh/authorized_keys/' + authorized_key.filename ) }} +{{ authorized_key.content | trim }} {% endif %} {% endfor %}