223 lines
6.9 KiB
YAML
223 lines
6.9 KiB
YAML
---
|
|
- 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
|