234 lines
8.4 KiB
YAML
234 lines
8.4 KiB
YAML
---
|
|
# 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 }}@<host>`
|
|
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."
|