--- - name: Validate platform and migration settings ansible.builtin.assert: that: - ansible_os_family == "Debian" - ansible_service_mgr == "systemd" - ssh_port | int > 0 - ssh_port | int <= 65535 - ssh_legacy_port | int > 0 - ssh_legacy_port | int <= 65535 - ansible_port | default(22) | int == ssh_port | int or (ssh_keep_legacy_port | bool and ansible_port | default(22) | int == ssh_legacy_port | int) fail_msg: >- The inventory connection port must be either the new SSH port or the legacy port while the legacy listener is intentionally retained. - name: Install hardening packages ansible.builtin.apt: name: - fail2ban - ufw - unattended-upgrades state: present update_cache: true cache_valid_time: 3600 - name: Enable UFW IPv6 support ansible.builtin.lineinfile: path: /etc/default/ufw regexp: '^IPV6=' line: 'IPV6=yes' backup: true - name: Set UFW default inbound policy community.general.ufw: direction: incoming default: "{{ ufw_default_incoming }}" - name: Set UFW default outbound policy community.general.ufw: direction: outgoing default: "{{ ufw_default_outgoing }}" - name: Set UFW default routed policy community.general.ufw: direction: routed default: "{{ ufw_default_routed }}" - name: Set UFW logging level community.general.ufw: logging: "{{ ufw_logging }}" - name: Build the SSH ports that must remain allowed during this run ansible.builtin.set_fact: hardening_ssh_ports: >- {{ ([ssh_port | string] + ([ssh_legacy_port | string] if ssh_keep_legacy_port | bool else [])) | unique }} - name: Allow configured SSH listener ports before enabling UFW community.general.ufw: rule: allow port: "{{ item }}" proto: tcp loop: "{{ hardening_ssh_ports }}" - name: Allow additional inventory-declared service ports community.general.ufw: rule: allow port: "{{ item.port | string }}" proto: "{{ item.proto | default('tcp') }}" from_ip: "{{ item.from_ip | default('any') }}" comment: "{{ item.comment | default(omit) }}" loop: "{{ ufw_allowed_rules }}" loop_control: label: "{{ item.proto | default('tcp') }}/{{ item.port }}" - name: Enable UFW after the SSH and service allow rules are installed community.general.ufw: state: enabled when: ufw_enabled | bool - name: Remove the legacy SSH firewall rule after port cutover community.general.ufw: rule: allow port: "{{ ssh_legacy_port | string }}" proto: tcp delete: true when: - not (ssh_keep_legacy_port | bool) - ssh_legacy_port | string != ssh_port | string - name: Remove explicitly retired inventory-declared UFW rules community.general.ufw: rule: allow port: "{{ item.port | string }}" proto: "{{ item.proto | default('tcp') }}" from_ip: "{{ item.from_ip | default('any') }}" delete: true loop: "{{ ufw_remove_rules }}" loop_control: label: "{{ item.proto | default('tcp') }}/{{ item.port }}" - name: Install Fail2ban SSH jail configuration ansible.builtin.copy: dest: /etc/fail2ban/jail.d/10-ansible-sshd.local owner: root group: root mode: "0644" content: | [DEFAULT] banaction = ufw ignoreip = 127.0.0.1/8 ::1 {{ fail2ban_ignore_ips | join(' ') }} [sshd] enabled = true backend = systemd port = {{ hardening_ssh_ports | join(',') }} maxretry = {{ fail2ban_ssh_maxretry }} findtime = {{ fail2ban_ssh_findtime }} bantime = {{ fail2ban_ssh_bantime }} notify: Restart fail2ban - name: Enable and start Fail2ban ansible.builtin.service: name: fail2ban enabled: true state: started - name: Detect active systemd SSH socket ansible.builtin.command: systemctl is-active --quiet ssh.socket register: hardening_ssh_socket changed_when: false failed_when: false - name: Detect active systemd SSH service ansible.builtin.command: systemctl is-active --quiet ssh.service register: hardening_ssh_service changed_when: false failed_when: false - name: Ensure the SSH socket override directory exists ansible.builtin.file: path: /etc/systemd/system/ssh.socket.d state: directory owner: root group: root mode: "0755" when: hardening_ssh_socket.rc == 0 - name: Set SSH socket listener ports when socket activation is active ansible.builtin.copy: dest: /etc/systemd/system/ssh.socket.d/zz-ansible-ports.conf owner: root group: root mode: "0644" content: | [Socket] ListenStream= {% for port in hardening_ssh_ports %} ListenStream={{ port }} {% endfor %} when: hardening_ssh_socket.rc == 0 notify: Apply SSH changes - name: Install SSH hardening drop-in ansible.builtin.copy: dest: /etc/ssh/sshd_config.d/00-ansible-hardening.conf owner: root group: root mode: "0644" content: | Port {{ ssh_port }} {% if ssh_keep_legacy_port | bool and ssh_legacy_port | int != ssh_port | int %}Port {{ ssh_legacy_port }} {% endif %}PermitRootLogin no PubkeyAuthentication yes PasswordAuthentication {{ 'yes' if ssh_password_authentication | bool else 'no' }} KbdInteractiveAuthentication {{ 'yes' if ssh_kbd_interactive_authentication | bool else 'no' }} PermitEmptyPasswords no MaxAuthTries {{ ssh_max_auth_tries }} LoginGraceTime {{ ssh_login_grace_time }} X11Forwarding no validate: /usr/sbin/sshd -t -f %s backup: true notify: Apply SSH changes - name: Apply pending service configuration changes ansible.builtin.meta: flush_handlers - name: Read effective SSH configuration ansible.builtin.command: /usr/sbin/sshd -T register: effective_sshd changed_when: false - name: Verify effective SSH hardening and listener ports ansible.builtin.assert: that: - "'permitrootlogin no' in effective_sshd.stdout_lines" - "'passwordauthentication ' + ('yes' if ssh_password_authentication | bool else 'no') in effective_sshd.stdout_lines" - "'pubkeyauthentication yes' in effective_sshd.stdout_lines" - "'port ' + (ssh_port | string) in effective_sshd.stdout_lines" - ssh_port | int == ssh_legacy_port | int or (ssh_keep_legacy_port | bool) == (('port ' + (ssh_legacy_port | string)) in effective_sshd.stdout_lines) - name: Wait for the new SSH listener locally on the host ansible.builtin.wait_for: host: 127.0.0.1 port: "{{ ssh_port }}" state: started timeout: 30 - name: Confirm the legacy SSH listener is closed after cutover ansible.builtin.wait_for: host: 127.0.0.1 port: "{{ ssh_legacy_port }}" state: stopped timeout: 10 when: - not (ssh_keep_legacy_port | bool) - ssh_legacy_port | int != ssh_port | int - name: Confirm Fail2ban has the SSH jail active ansible.builtin.command: fail2ban-client status sshd register: fail2ban_sshd_status changed_when: false failed_when: fail2ban_sshd_status.rc != 0