--- # Hardens sshd on every reachable Ubuntu/Rocky guest and turns on a fail2ban # sshd jail. Settings go into /etc/ssh/sshd_config.d/00-ansible.conf, which # sorts first and therefore wins over anything in sshd_config or the # cloud-init drop-in (sshd keeps the first value it sees for each option). # # Lockout guard: password authentication is only disabled on hosts where # root already has at least one authorized key. # # Extra vars (optional): # ssh_disable_password_auth - default true # ssh_max_auth_tries - default 4 - name: Check Host Availability hosts: ubuntu:rocky gather_facts: false ignore_unreachable: true tasks: - name: Ping hosts to check availability ansible.builtin.ping: register: ping_result ignore_errors: true ignore_unreachable: true - name: Add reachable hosts to group ansible.builtin.group_by: key: reachable_hosts when: ping_result is succeeded - name: Report offline hosts ansible.builtin.debug: msg: "{{ inventory_hostname }} - OFFLINE" when: ping_result is not succeeded delegate_to: localhost - name: SSH Hardening - Install fail2ban on Ubuntu hosts: ubuntu:&reachable_hosts become: true gather_facts: false tasks: - name: Install fail2ban and nftables via apt ansible.builtin.apt: name: - fail2ban - nftables state: present update_cache: true cache_valid_time: 3600 - name: SSH Hardening - Install fail2ban on Rocky Linux hosts: rocky:&reachable_hosts become: true gather_facts: false tasks: - name: Ensure EPEL repo is enabled ansible.builtin.dnf: name: epel-release state: present - name: Install fail2ban and nftables via dnf ansible.builtin.dnf: name: - fail2ban - nftables state: present - name: SSH Hardening - Configure sshd and fail2ban hosts: reachable_hosts become: true gather_facts: false vars: ssh_disable_password_auth: true ssh_max_auth_tries: 4 sshd_dropin_path: /etc/ssh/sshd_config.d/00-ansible.conf tasks: - name: Count public keys in root's authorized_keys ansible.builtin.command: grep -cE '^(ssh-|ecdsa-|sk-)' /root/.ssh/authorized_keys register: key_count failed_when: false changed_when: false - name: Decide whether password authentication can be disabled ansible.builtin.set_fact: has_keys: "{{ key_count.rc == 0 and (key_count.stdout | int) > 0 }}" - name: Warn when root has no authorized keys ansible.builtin.debug: msg: "WARNING: no authorized keys for root on {{ inventory_hostname }} - password authentication stays enabled" when: not has_keys - name: Ensure sshd_config.d directory exists ansible.builtin.file: path: /etc/ssh/sshd_config.d state: directory owner: root group: root mode: '0755' - name: Ensure sshd_config includes the drop-in directory ansible.builtin.lineinfile: path: /etc/ssh/sshd_config regexp: '^Include\s+/etc/ssh/sshd_config\.d/\*\.conf' line: 'Include /etc/ssh/sshd_config.d/*.conf' insertbefore: BOF backup: true register: sshd_include - name: Write sshd hardening drop-in ansible.builtin.copy: dest: "{{ sshd_dropin_path }}" content: | # Managed by Ansible (playbooks/ssh_harden.yml) - do not edit by hand PermitRootLogin prohibit-password PubkeyAuthentication yes PasswordAuthentication {{ 'no' if (has_keys and ssh_disable_password_auth | bool) else 'yes' }} KbdInteractiveAuthentication no MaxAuthTries {{ ssh_max_auth_tries }} X11Forwarding no owner: root group: root mode: '0600' validate: /usr/sbin/sshd -t -f %s register: sshd_dropin - name: Record whether sshd needs a restart ansible.builtin.set_fact: sshd_changed: "{{ sshd_include is changed or sshd_dropin is changed }}" - name: Configure fail2ban sshd jail ansible.builtin.copy: dest: /etc/fail2ban/jail.d/sshd.local content: | # Managed by Ansible (playbooks/ssh_harden.yml) [sshd] enabled = true backend = systemd banaction = nftables-multiport maxretry = 5 findtime = 10m bantime = 1h owner: root group: root mode: '0644' register: f2b_jail - name: Ensure fail2ban is enabled and running ansible.builtin.systemd: name: fail2ban enabled: true state: "{{ 'restarted' if f2b_jail is changed else 'started' }}" - name: SSH Hardening - Restart SSH on Ubuntu hosts: ubuntu:&reachable_hosts become: true gather_facts: false tasks: - name: Restart ssh service ansible.builtin.service: name: ssh state: restarted when: sshd_changed | bool - name: SSH Hardening - Restart SSH on Rocky Linux hosts: rocky:&reachable_hosts become: true gather_facts: false tasks: - name: Restart sshd service ansible.builtin.service: name: sshd state: restarted when: sshd_changed | bool - name: SSH Hardening - Verify access hosts: reachable_hosts become: true gather_facts: false tasks: - name: Drop the cached SSH connection so the next task performs a fresh login ansible.builtin.meta: reset_connection - name: Confirm key-based login still works ansible.builtin.ping: - name: Read fail2ban sshd jail status ansible.builtin.command: fail2ban-client status sshd register: f2b_status changed_when: false failed_when: false - name: Summary ansible.builtin.debug: msg: | ====================================================== HOST: {{ inventory_hostname }} ({{ ansible_host }}) ====================================================== PASSWORD AUTH: {{ 'disabled' if (has_keys and ssh_disable_password_auth | default(true) | bool) else 'ENABLED' }} ROOT KEYS: {{ key_count.stdout if key_count.rc == 0 else 0 }} SSHD: {{ 'restarted' if sshd_changed | bool else 'unchanged' }} FAIL2BAN: {{ 'sshd jail active' if f2b_status.rc == 0 else 'WARNING: jail not reporting (' ~ (f2b_status.stderr | default('') | trim) ~ ')' }} ======================================================