|
10 | 10 | cache_valid_time: 3600 |
11 | 11 |
|
12 | 12 | # --- Admin account (must exist + have a key BEFORE ssh_hardening locks root) --- |
| 13 | +# Resolve the user and key set from the control machine when left empty; explicit |
| 14 | +# values always win. Lookups run on the control node — that's the operator's box. |
| 15 | +- name: Resolve the admin sudo username (local $USER if unset) |
| 16 | + ansible.builtin.set_fact: |
| 17 | + ssh_admin_user_effective: >- |
| 18 | + {{ ssh_admin_user if ssh_admin_user | length > 0 |
| 19 | + else lookup('ansible.builtin.env', 'USER') }} |
| 20 | +
|
| 21 | +- name: Autodetect the operator's local SSH public key (control machine) |
| 22 | + ansible.builtin.set_fact: |
| 23 | + ssh_admin_detected_pubkey: "{{ lookup('ansible.builtin.file', found) | trim }}" |
| 24 | + vars: |
| 25 | + found: >- |
| 26 | + {{ query('ansible.builtin.first_found', |
| 27 | + {'files': candidates, 'skip': true}) | first | default('') }} |
| 28 | + candidates: |
| 29 | + - "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_ed25519.pub" |
| 30 | + - "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_ecdsa.pub" |
| 31 | + - "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_rsa.pub" |
| 32 | + when: |
| 33 | + - ssh_admin_pubkey | length == 0 |
| 34 | + - ssh_admin_pubkey_autodetect | bool |
| 35 | + - found | length > 0 |
| 36 | + |
| 37 | +- name: Resolve the set of admin authorized keys |
| 38 | + ansible.builtin.set_fact: |
| 39 | + ssh_admin_keys: >- |
| 40 | + {{ (([ssh_admin_pubkey] if ssh_admin_pubkey | length > 0 |
| 41 | + else [ssh_admin_detected_pubkey | default('')]) |
| 42 | + + ssh_admin_extra_pubkeys) |
| 43 | + | map('trim') | reject('equalto', '') | unique | list }} |
| 44 | +
|
| 45 | +# UNCONDITIONAL lockout guard — runs BEFORE the account is created and hardening |
| 46 | +# applied. It is the single gate that makes a keyless/root-only harden impossible; |
| 47 | +# downstream tasks therefore need no `when`. ssh_hardening disables root login, so a |
| 48 | +# resolved admin user of "root" (or an empty one, e.g. $USER unset under cron/CI) |
| 49 | +# would lock the host out — both are refused here rather than skipped silently. |
| 50 | +- name: Refuse to harden SSH without a non-root admin user and a key (lockout guard) |
| 51 | + ansible.builtin.assert: |
| 52 | + that: |
| 53 | + - ssh_admin_user_effective | length > 0 |
| 54 | + - ssh_admin_user_effective != 'root' |
| 55 | + - ssh_admin_keys | length > 0 |
| 56 | + fail_msg: >- |
| 57 | + Cannot establish a working admin login before devsec.hardening.ssh_hardening |
| 58 | + disables root + password auth. Resolved user='{{ ssh_admin_user_effective }}', |
| 59 | + keys={{ ssh_admin_keys | length }}. Causes: ssh_admin_user is empty and the |
| 60 | + control-machine $USER is unset (cron/CI/sudo with env_reset) -> set |
| 61 | + ssh_admin_user; the resolved user is 'root' (hardening forbids root login) -> |
| 62 | + set ssh_admin_user to a non-root account; or no key was found -> set |
| 63 | + ssh_admin_pubkey, add ssh_admin_extra_pubkeys, or place a default key |
| 64 | + (~/.ssh/id_ed25519.pub, id_ecdsa.pub, or id_rsa.pub) on the control machine. |
| 65 | +
|
| 66 | +# Surface WHAT will be trusted: autodetect installs whatever key sits in the |
| 67 | +# control box's ~/.ssh, so echo the resolved user + key count (a silently dropped |
| 68 | +# malformed entry, or a wrong control machine, shows up as an unexpected count). |
| 69 | +- name: Report the resolved admin user and key count |
| 70 | + ansible.builtin.debug: |
| 71 | + msg: >- |
| 72 | + Admin user '{{ ssh_admin_user_effective }}' will be authorized with |
| 73 | + {{ ssh_admin_keys | length }} key(s) |
| 74 | + ({{ 'explicit ssh_admin_pubkey' if ssh_admin_pubkey | length > 0 |
| 75 | + else 'autodetected ~/.ssh key' }} |
| 76 | + + {{ ssh_admin_extra_pubkeys | length }} extra). |
| 77 | +
|
13 | 78 | - name: Create admin sudo user |
14 | 79 | ansible.builtin.user: |
15 | | - name: "{{ ssh_admin_user }}" |
| 80 | + name: "{{ ssh_admin_user_effective }}" |
16 | 81 | groups: [sudo] |
17 | 82 | append: true |
18 | 83 | shell: /bin/bash |
19 | 84 | create_home: true |
20 | | - when: ssh_admin_user | length > 0 |
21 | 85 |
|
22 | | -- name: Install admin authorized key |
| 86 | +- name: Install admin authorized keys |
23 | 87 | ansible.posix.authorized_key: |
24 | | - user: "{{ ssh_admin_user }}" |
25 | | - key: "{{ ssh_admin_pubkey }}" |
| 88 | + user: "{{ ssh_admin_user_effective }}" |
| 89 | + key: "{{ item }}" |
26 | 90 | state: present |
27 | | - when: |
28 | | - - ssh_admin_user | length > 0 |
29 | | - - ssh_admin_pubkey | length > 0 |
30 | | - |
31 | | -- name: Refuse to harden SSH without a working admin key (lockout guard) |
32 | | - ansible.builtin.assert: |
33 | | - that: |
34 | | - - ssh_admin_user | length > 0 |
35 | | - - ssh_admin_pubkey | length > 0 |
36 | | - fail_msg: >- |
37 | | - ssh_admin_user and ssh_admin_pubkey must be set so the admin account can log |
38 | | - in BEFORE devsec.hardening.ssh_hardening disables root and password auth. |
39 | | - Set them in inventory/group_vars/all.yml. (Molecule sets both to "" to skip |
40 | | - SSH hardening inside the throwaway container.) |
41 | | - when: ssh_admin_user | length > 0 |
| 91 | + loop: "{{ ssh_admin_keys }}" |
42 | 92 |
|
43 | 93 | # --- Firewall: default-deny inbound, SSH only --------------------------------- |
44 | 94 | - name: Install nftables ruleset |
|
99 | 149 | ansible.builtin.include_role: |
100 | 150 | name: devsec.hardening.os_hardening |
101 | 151 |
|
| 152 | +# No `when` guard here by design: the unconditional lockout assert above has already |
| 153 | +# proven a non-root admin user with >=1 key exists (or aborted the play), so hardening |
| 154 | +# can never run keyless. Re-adding a gate here would risk drifting out of sync with it. |
102 | 155 | - name: Apply DevSec SSH hardening |
103 | 156 | ansible.builtin.include_role: |
104 | 157 | name: devsec.hardening.ssh_hardening |
105 | | - when: ssh_admin_user | length > 0 |
|
0 commit comments