--- # ssh-hardening.yml # ============================================================================= # Disable SSH password authentication and apply baseline hardening on Ubuntu. # # Usage: # ansible-playbook -i inventory.ini ssh-hardening.yml # ansible-playbook -i inventory.ini ssh-hardening.yml --limit coding # ansible-playbook -i inventory.ini ssh-hardening.yml --check # dry run # # Example inventory.ini: # [servers] # coding ansible_host=192.168.1.21 ansible_user=justin # # SAFETY FEATURES: # * Refuses to run if the login user has no authorized_keys (no lockout). # * Fixes the cloud-init "first value wins" precedence trap automatically. # * Validates config with `sshd -t` BEFORE restarting. # * Verifies password auth is actually off at the end via `sshd -T`. # # Optional extras (fail2ban / UFW) use the community.general collection: # ansible-galaxy collection install community.general # ============================================================================= - name: Harden SSH (key-only authentication + baseline) hosts: all become: true vars: # User whose key access is verified before passwords are disabled. # Defaults to whoever Ansible connects as. ssh_login_user: "{{ ansible_user | default(lookup('env', 'USER')) }}" # ---- Core SSH settings ------------------------------------------------- ssh_permit_root_login: "prohibit-password" # "no" to block root entirely ssh_allow_users: [] # e.g. ["justin", "deploy"]; [] = no restriction ssh_port: 22 # change to move SSH off 22 # ---- Optional hardening toggles --------------------------------------- manage_firewall: false # true = install + enable UFW install_fail2ban: false install_unattended_upgrades: false handlers: # Handlers run in the order defined here, so validation always precedes restart. - name: Validate sshd config ansible.builtin.command: sshd -t changed_when: false listen: "restart ssh stack" - name: Restart ssh ansible.builtin.systemd: name: ssh state: restarted listen: "restart ssh stack" - name: Restart ssh.socket ansible.builtin.systemd: name: ssh.socket state: restarted when: ssh_socket_present | default(false) | bool listen: "restart ssh stack" tasks: # ---- Pre-flight: prove we won't lock ourselves out -------------------- - name: Resolve login user's home directory ansible.builtin.getent: database: passwd key: "{{ ssh_login_user }}" - name: Set authorized_keys path ansible.builtin.set_fact: ssh_authorized_keys_path: "{{ getent_passwd[ssh_login_user][4] }}/.ssh/authorized_keys" - name: Stat the login user's authorized_keys ansible.builtin.stat: path: "{{ ssh_authorized_keys_path }}" register: ak - name: Refuse to continue if no SSH key is present ansible.builtin.assert: that: - ak.stat.exists - (ak.stat.size | default(0) | int) > 0 fail_msg: >- {{ ssh_login_user }} has no authorized_keys at {{ ssh_authorized_keys_path }}. Aborting so you don't get locked out. Run `ssh-copy-id {{ ssh_login_user }}@` first, verify key login works, then re-run this playbook. success_msg: "Key-based auth verified for {{ ssh_login_user }} - safe to proceed." # ---- Detect socket activation (Ubuntu 22.10+ / 24.04 / 26.04) --------- - name: Check whether ssh.socket exists ansible.builtin.command: systemctl list-unit-files ssh.socket register: ssh_socket_check changed_when: false failed_when: false - name: Record socket-activation state ansible.builtin.set_fact: ssh_socket_present: "{{ 'ssh.socket' in ssh_socket_check.stdout }}" # ---- Fix the cloud-init precedence trap ------------------------------- # sshd uses the FIRST value it sees for a directive. Files load in # alphanumeric order, so 50-cloud-init.conf (PasswordAuthentication yes) # beats a later 99-hardening.conf. Neutralize it at the source. - name: Check for the cloud-init SSH drop-in ansible.builtin.stat: path: /etc/ssh/sshd_config.d/50-cloud-init.conf register: cloud_init_conf - name: Force PasswordAuthentication off in the cloud-init drop-in ansible.builtin.lineinfile: path: /etc/ssh/sshd_config.d/50-cloud-init.conf regexp: '^\s*PasswordAuthentication\s+' line: "PasswordAuthentication no" state: present when: cloud_init_conf.stat.exists notify: "restart ssh stack" # ---- Authoritative hardening drop-in ---------------------------------- - name: Deploy SSH hardening drop-in ansible.builtin.copy: dest: /etc/ssh/sshd_config.d/99-hardening.conf owner: root group: root mode: "0644" validate: "sshd -t -f %s" content: | # Managed by Ansible - ssh-hardening.yml. Do not edit by hand. PasswordAuthentication no PubkeyAuthentication yes KbdInteractiveAuthentication no PermitRootLogin {{ ssh_permit_root_login }} {% if ssh_allow_users | length > 0 %} AllowUsers {{ ssh_allow_users | join(' ') }} {% endif %} {% if ssh_port != 22 %} Port {{ ssh_port }} {% endif %} notify: "restart ssh stack" # ---- Stop cloud-init from re-enabling passwords on rebuild ------------ - name: Check for cloud-init config directory ansible.builtin.stat: path: /etc/cloud/cloud.cfg.d register: cloud_init_dir - name: Pin ssh_pwauth off for cloud-init ansible.builtin.copy: dest: /etc/cloud/cloud.cfg.d/99-disable-password-auth.cfg owner: root group: root mode: "0644" content: | # Managed by Ansible - ssh-hardening.yml ssh_pwauth: false when: cloud_init_dir.stat.exists # ---- Optional: UFW firewall ------------------------------------------- - name: Configure UFW firewall when: manage_firewall | bool block: - name: Install UFW ansible.builtin.apt: name: ufw state: present update_cache: true - name: Allow the SSH port through UFW community.general.ufw: rule: allow port: "{{ ssh_port }}" proto: tcp - name: Enable UFW with a default-deny inbound policy community.general.ufw: state: enabled policy: deny direction: incoming # ---- Optional: fail2ban ----------------------------------------------- - name: Install and enable fail2ban when: install_fail2ban | bool block: - name: Install fail2ban ansible.builtin.apt: name: fail2ban state: present update_cache: true - name: Enable and start fail2ban ansible.builtin.systemd: name: fail2ban enabled: true state: started # ---- Optional: unattended security upgrades --------------------------- - name: Enable unattended security upgrades when: install_unattended_upgrades | bool block: - name: Install unattended-upgrades ansible.builtin.apt: name: unattended-upgrades state: present update_cache: true - name: Turn on periodic update + upgrade ansible.builtin.copy: dest: /etc/apt/apt.conf.d/20auto-upgrades owner: root group: root mode: "0644" content: | APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; # ---- Apply restarts, then verify the result --------------------------- - name: Apply any pending SSH restarts now ansible.builtin.meta: flush_handlers - name: Read the effective sshd configuration ansible.builtin.command: sshd -T register: sshd_effective changed_when: false - name: Confirm password authentication is actually disabled ansible.builtin.assert: that: - "'passwordauthentication no' in sshd_effective.stdout" fail_msg: >- Password auth is STILL enabled in the effective config. Check drop-in precedence with: sudo sshd -T | grep -i passwordauth success_msg: "Confirmed: password authentication is disabled."