Add reusable Debian and Ubuntu hardening playbooks

This commit is contained in:
2026-09-29 00:07:10 +04:00
commit 3d08c8f1ff
17 changed files with 663 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Ansible generated files
*.retry
*.vault-password
# Inventory is local to each checkout. Track *.yml.example templates instead.
/inventory/*.yml
/inventory/**/*.yml
# Local secrets and runtime state
.env
.env.*
!*.env.example
vault.yml
vault.yaml
**/vault.yml
**/vault.yaml
!inventory/group_vars/all/vault.yml.example
secrets/
data/
letsencrypt/
# Python tooling
.venv/
__pycache__/
*.py[cod]
+41
View File
@@ -0,0 +1,41 @@
# VPS administration
Reusable Ansible configuration for Debian and Ubuntu servers. Host hardening is implemented first; Docker Compose services will be added in separate directories as the rollout progresses. The local checkout can bootstrap Gitea before it becomes a Git remote.
## Set up a fresh clone
Run Ansible from a macOS or Linux controller with SSH access to the target. Ansible does not support native Windows as a control node.
```sh
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements-controller.txt
ansible-galaxy collection install -r requirements.yml
cp inventory/hosts.yml.example inventory/hosts.yml
cp inventory/group_vars/all.yml.example inventory/group_vars/all.yml
cp inventory/host_vars/server1.yml.example inventory/host_vars/server1.yml
```
Edit `inventory/hosts.yml` with the host alias, address, existing SSH account, and current SSH port. Keep hosts you intend to manage under the `debian_servers` group. Rename each `inventory/host_vars/<alias>.yml` to match its alias; set the admin public keys and the ports its services need. Edit `inventory/group_vars/all.yml` for shared defaults. Host-specific values can override them in `host_vars`.
All active inventory `.yml` files are Git ignored. The `.yml.example` templates are tracked, so a clone has a starting point without committing server addresses, access details, or local policy. Create and encrypt `inventory/group_vars/all/vault.yml` as described in [docs/hardening.md](docs/hardening.md).
For the first server, run discovery and then follow the staged [hardening procedure](docs/hardening.md):
```sh
ansible-playbook playbooks/discover.yml -l server1
```
Replace `server1` with the chosen inventory alias. Both the bootstrap and hardening playbooks target `debian_servers` and process hosts one at a time. Use `-l <alias>` for the first rollout.
## Repository layout
- `inventory/`: local host definitions and tracked examples.
- `playbooks/`: discovery, admin bootstrap, and hardening entry points.
- `roles/`: reusable Ansible tasks.
- `docs/`: rollout and security procedures.
- `stacks/<service>/`: planned separate Compose projects for each service.
Planned app stacks will join one external `traefik` network when they need HTTP routing. Traefik route fragments and DIUN image watch files will live alongside each service. The [rollout plan](docs/plan.md) records the Pi pilot and later service deployment.
Commit no plaintext passwords, tokens, private keys, or runtime `.env` files. Keep the Ansible Vault password outside this repository.
+10
View File
@@ -0,0 +1,10 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
host_key_checking = True
retry_files_enabled = False
interpreter_python = auto_silent
[ssh_connection]
pipelining = True
timeout = 15
+70
View File
@@ -0,0 +1,70 @@
# Host hardening
The playbooks target Debian or Ubuntu hosts using systemd. They create a separate admin account with password-required sudo and SSH public key access, configure Fail2ban and UFW, enable unattended security updates, disable SSH root and password login, and move SSH to a configurable port. The default replacement port is `2222`; changing it reduces scan noise but is not a security boundary.
## Configure inventory
Copy the tracked inventory examples as shown in [README.md](../README.md). Add each server under the `debian_servers` group in `inventory/hosts.yml`. Set `ansible_user` to an existing account with sudo access and `ansible_port` to its current SSH port. Put the admin public key in `inventory/host_vars/<alias>.yml`. Set `admin_user`, `ssh_port`, and `ssh_legacy_port` in the shared settings or override them per host. Every active inventory `.yml` file is ignored by Git.
Create a SHA-512 crypt password hash with `openssl passwd -6`, which prompts for the password. Copy `inventory/group_vars/all/vault.yml.example` to `inventory/group_vars/all/vault.yml`, insert the hash, and encrypt the file:
```sh
ansible-vault encrypt inventory/group_vars/all/vault.yml
```
Keep the Vault password outside the repository. The password hash is encrypted in Vault and its Ansible task output is suppressed.
## Configure ingress
Add only required service ports to `ufw_allowed_rules` in a host's inventory file:
```yaml
ufw_allowed_rules:
- port: "80"
proto: tcp
- port: "443"
proto: tcp
- port: "51820"
proto: udp
```
SSH ports are handled by `ssh_port`, `ssh_legacy_port`, and `ssh_keep_legacy_port`. A rule may set `from_ip` to restrict its source. To remove an existing rule, move its port, protocol, and source into `ufw_remove_rules`, run the playbook once, then remove that cleanup entry. UFW defaults to denying inbound and routed traffic and allowing outbound traffic.
Docker-published ports can bypass UFW's normal host INPUT filtering. Publish only intended host ports and keep application and database ports on Docker networks or bind them locally.
## Roll out one host
Replace `server1` with the host alias from `inventory/hosts.yml` in these commands. Keep a working SSH session open during the port change.
1. Inspect the host and its current listeners:
```sh
ansible-playbook playbooks/discover.yml -l server1
```
2. Bootstrap the admin account through the existing SSH account:
```sh
ansible-playbook playbooks/bootstrap-admin.yml -l server1 --ask-become-pass --ask-vault-pass
```
Omit `--ask-become-pass` if the existing account already has passwordless sudo. Verify a fresh SSH login as `admin_user` on the old port and check that `sudo` asks for that account's password.
3. Change `ansible_user` in the host inventory to `admin_user`. Keep `ansible_port` on the old port, and leave `ssh_keep_legacy_port: true`. Apply hardening:
```sh
ansible-playbook playbooks/harden.yml -l server1 -K --ask-vault-pass
```
UFW allows both SSH ports before it is enabled. SSH then listens on both ports. On hosts using `ssh.socket`, the playbook updates the systemd socket listeners as well.
4. Verify a fresh SSH login as the admin on `ssh_port`, plus UFW and Fail2ban status. Change `ansible_port` to `ssh_port`, set `ssh_keep_legacy_port: false`, and rerun the hardening command. The old SSH listener and its UFW rule are then removed.
5. Add or retire future service ports through that host's `ufw_allowed_rules` and `ufw_remove_rules`, then rerun the hardening playbook with `-l <alias>`.
## Ongoing work
- Review native listeners before closing a service port.
- Review container port publishing separately from UFW rules.
- Keep secrets in Vault and runtime secret files, and verify a backup restore.
- Review container updates after DIUN alerts; they are not applied automatically.
+47
View File
@@ -0,0 +1,47 @@
# Pi-first rollout plan
The Pi is the first inventory target for validation. The Ansible playbooks target the generic `debian_servers` group and can be used for any Debian or Ubuntu host with per-host inventory values.
## Agreed design
- Ansible is the host configuration and deployment controller.
- Docker Compose defines each service in its own `stacks/<service>/` directory.
- The local Git checkout bootstraps Gitea; after Gitea is available, it is added as a remote.
- Traefik is the only public HTTP(S) edge. It uses file-provider route fragments supplied alongside each service, rather than Docker labels requiring access to the Docker API.
- All proxied services join the externally created `traefik` network. Databases and internal workers remain on per-stack private networks.
- DIUN alerts about registry image changes. It uses its file provider and per-service image watch files, so it needs no Docker socket. Alerts prompt a reviewed update; they do not auto-update services.
- restic backs up important persistent data to a separate destination with storage-aware retention and a tested restore procedure.
- Portainer is out of scope.
## Pi facts collected
- Raspberry Pi 4 Model B Rev 1.1, 4 GB RAM, 64-bit ARM.
- Debian GNU/Linux 13 (Trixie), kernel 6.12.47+rpt-rpi-v8.
- Docker Engine 28.5.1 and Compose v2.40.0 are installed.
- Root filesystem: 115 GB, about 101 GB available at discovery.
- SSH is listening on all IPv4 interfaces at port 22. Root key login and password login are currently permitted. `ufw` is not installed and Fail2ban is inactive. The provided Windows public key was not in `/home/pi/.ssh/authorized_keys`; the `pi` password login still works.
- Containers `egress-proxy` and `uptime-kuma` were stopped at the owner's request. Their files and data were left untouched.
- Native Nginx (80/tcp) and sing-box (2080/tcp, 2081/tcp) are still running. The Pi inventory temporarily includes these ports in the UFW allow-list so the firewall rollout does not silently cut them off. Remove them from inventory when the services are retired or migrated.
## Steps
1. Resolve the Windows-to-Pi SSH route conflict: Cisco AnyConnect installs a route for the Pi's LAN subnet through its VPN interface. With Cisco disconnected, Windows SSH works on the direct Ethernet route. Separately, the installed Windows Ansible launcher still fails at startup (`WinError 87`); Ansible documents Windows as unsupported as a control node. WSL is excluded, so the playbooks are authored here but cannot be executed from this Windows Ansible install until a supported non-WSL controller is available.
2. Create the `ops` admin account with the provided key and a Vault-protected password hash; verify key login and password-required sudo while the existing `pi` session/port remains available.
3. Apply the baseline, Fail2ban, UFW allow-list, root-login denial, and key-only SSH on both port 22 and the configured replacement port.
4. Verify a fresh connection to the replacement SSH port. Only after that succeeds, update `ansible_port`, set `ssh_keep_legacy_port: false`, and redeploy to remove port 22 from sshd and UFW.
5. Add future service ingress by editing that host's `ufw_allowed_rules` list. Retire a port through `ufw_remove_rules` for one run. Keep Docker host-port behavior separate from UFW INPUT rules.
6. Create the external Traefik network and deploy Traefik, DIUN, and Uptime Kuma from their separate Compose directories. Initially use test ports that avoid the Pi's native Nginx port 80. Decide whether to reuse or replace existing Uptime Kuma data before mounting it.
7. Configure DIUN image notifications (notification channel/credentials still need choosing). Notifications prompt reviewed image updates; DIUN does not update containers automatically.
8. Verify routing, notification delivery, reboot persistence, and a storage-aware restic backup/restore on the Pi.
9. Deploy Gitea and push the local repository to it. Add 3x-ui, password manager, Logseq sync, portfolio, and pet projects individually, each in its own stack directory with an optional Traefik route fragment and DIUN watch file.
10. Once the Pi runbook is stable, add the Ubuntu VPS and planned nearby VPS as inventory hosts, with per-host variables as needed.
## Safety gates
- No automated reboot during the first pilot.
- No deletion or migration of existing application data before a backup and an explicit service cutover plan.
- Bootstrap and verify the new admin account before disabling SSH password authentication.
- Keep the old SSH port listening and allowed through the first hardening run. Change inventory to the new port only after a second SSH login succeeds; remove the old port in a later run.
- Keep an existing SSH session open throughout the port migration.
- Review UFW allow-list changes against required native and Docker-published services before applying.
- Container updates remain manual and reviewable after DIUN alerts.
+36
View File
@@ -0,0 +1,36 @@
---
stack_root: /opt/stacks
traefik_network: traefik
diun_watch_root: "{{ stack_root }}/diun/watch"
# Start with an existing account. After bootstrapping and verifying this one,
# change ansible_user in hosts.yml to the account named here.
admin_user: ops
admin_sudo_group: sudo
admin_user_shell: /bin/bash
admin_user_password_hash: "{{ vault_admin_user_password_hash | default('') }}"
# Keep the current SSH port open through the first hardening run. After a
# successful login on ssh_port, update ansible_port and set this to false.
ssh_port: 2222
ssh_legacy_port: 22
ssh_keep_legacy_port: true
ssh_max_auth_tries: 4
ssh_login_grace_time: 30
ssh_password_authentication: false
ssh_kbd_interactive_authentication: false
ufw_enabled: true
ufw_logging: low
ufw_default_incoming: deny
ufw_default_outgoing: allow
ufw_default_routed: deny
ufw_allowed_rules: []
ufw_remove_rules: []
fail2ban_ssh_maxretry: 5
fail2ban_ssh_findtime: 10m
fail2ban_ssh_bantime: 1h
fail2ban_ignore_ips: []
compose_stacks: []
@@ -0,0 +1,4 @@
---
# Copy to vault.yml, replace this value with the output of `openssl passwd -6`,
# then encrypt vault.yml with `ansible-vault encrypt inventory/group_vars/all/vault.yml`.
vault_admin_user_password_hash: "$6$REPLACE_WITH_A_REAL_SHA512_CRYPT_HASH"
+12
View File
@@ -0,0 +1,12 @@
---
# Rename this file to match the host alias in hosts.yml.
admin_user_authorized_keys:
- "ssh-ed25519 REPLACE_WITH_YOUR_PUBLIC_KEY admin@laptop"
# Add only inbound ports required by this host's services. SSH is managed by
# ssh_port and ssh_keep_legacy_port, so do not duplicate it here.
ufw_allowed_rules: []
# To retire an existing UFW rule, list it here for one deployment, then remove
# it from this cleanup list.
ufw_remove_rules: []
+9
View File
@@ -0,0 +1,9 @@
---
all:
children:
debian_servers:
hosts:
server1:
ansible_host: 192.0.2.10
ansible_user: existing_admin
ansible_port: 22
+23
View File
@@ -0,0 +1,23 @@
---
- name: Create the password-protected sudo admin account
hosts: debian_servers
become: true
gather_facts: true
serial: 1
pre_tasks:
- name: Check that this host is a supported Debian-family system
ansible.builtin.assert:
that:
- ansible_distribution in ["Debian", "Ubuntu"]
- ansible_service_mgr == "systemd"
- admin_user != "root"
- admin_user_password_hash is match('^\\$6\\$')
- admin_user_authorized_keys is defined
- admin_user_authorized_keys | length > 0
fail_msg: >-
Set an admin username, a SHA-512 crypt password hash in Ansible Vault,
and at least one authorized public key before bootstrapping.
roles:
- role: admin_user
+32
View File
@@ -0,0 +1,32 @@
---
- name: Read-only host discovery
hosts: debian_servers
gather_facts: true
tasks:
- name: Show platform facts
ansible.builtin.debug:
msg:
- "Host: {{ inventory_hostname }} ({{ ansible_host | default(inventory_hostname) }})"
- "Distribution: {{ ansible_distribution }} {{ ansible_distribution_version }}"
- "Kernel: {{ ansible_kernel }}"
- "Architecture: {{ ansible_architecture }}"
- "Memory: {{ ansible_memtotal_mb }} MiB"
- "Default interface: {{ ansible_default_ipv4.interface | default('unknown') }}"
- name: Read filesystem capacity
ansible.builtin.command: df -h /
register: root_fs
changed_when: false
- name: Read listening TCP and UDP sockets
ansible.builtin.command: ss -H -tulpn
register: listeners
changed_when: false
become: true
- name: Report non-secret discovery results
ansible.builtin.debug:
msg:
filesystem: "{{ root_fs.stdout_lines }}"
listeners: "{{ listeners.stdout_lines }}"
+52
View File
@@ -0,0 +1,52 @@
---
- name: Apply operating-system, SSH, Fail2ban, and UFW hardening
hosts: debian_servers
become: true
gather_facts: true
serial: 1
pre_tasks:
- name: Require a supported Debian or Ubuntu system
ansible.builtin.assert:
that:
- ansible_distribution in ["Debian", "Ubuntu"]
- ansible_service_mgr == "systemd"
fail_msg: >-
This playbook targets Debian or Ubuntu hosts using systemd. Review
the inventory and platform facts before applying it elsewhere.
- name: Confirm UFW is configured to preserve the current SSH path
ansible.builtin.assert:
that:
- 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: >-
Keep the current Ansible connection port in sshd and UFW during the
first run. After confirming the new port works, update ansible_port
and rerun with ssh_keep_legacy_port=false.
roles:
- role: baseline
tags: [baseline]
- role: hardening
tags: [hardening, ssh, fail2ban, ufw]
handlers:
- name: Restart active SSH socket after reloading systemd
ansible.builtin.systemd_service:
name: ssh.socket
daemon_reload: true
state: restarted
listen: Apply SSH changes
when: hardening_ssh_socket.rc == 0
- name: Reload ssh
ansible.builtin.service:
name: ssh
state: reloaded
listen: Apply SSH changes
when: hardening_ssh_socket.rc != 0 or hardening_ssh_service.rc == 0
- name: Restart fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted
+2
View File
@@ -0,0 +1,2 @@
# Pin the supported control-node runtime for reproducible playbook execution.
ansible-core==2.21.4
+8
View File
@@ -0,0 +1,8 @@
---
collections:
- name: ansible.posix
version: "2.2.2"
- name: community.general
version: "13.4.0"
- name: community.docker
version: "5.3.0"
+44
View File
@@ -0,0 +1,44 @@
---
- name: Install sudo before creating the admin account
ansible.builtin.apt:
name: sudo
state: present
update_cache: true
cache_valid_time: 3600
- name: Ensure the sudo group exists
ansible.builtin.group:
name: "{{ admin_sudo_group }}"
state: present
- name: Create the admin account with a password for sudo
ansible.builtin.user:
name: "{{ admin_user }}"
comment: Server administrator
shell: "{{ admin_user_shell }}"
groups: "{{ admin_sudo_group }}"
append: true
create_home: true
password: "{{ admin_user_password_hash }}"
password_lock: false
update_password: always
state: present
no_log: true
- name: Require the admin account to enter its password for sudo
ansible.builtin.copy:
dest: "/etc/sudoers.d/90-{{ admin_user }}-password-required"
owner: root
group: root
mode: "0440"
content: "{{ admin_user }} ALL=(ALL:ALL) PASSWD: ALL\n"
validate: /usr/sbin/visudo -cf %s
- name: Install the admin account's authorized SSH keys
ansible.posix.authorized_key:
user: "{{ admin_user }}"
key: "{{ item }}"
state: present
manage_dir: true
exclusive: false
loop: "{{ admin_user_authorized_keys }}"
+26
View File
@@ -0,0 +1,26 @@
---
- name: Refresh apt package metadata
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install unattended security updates
ansible.builtin.apt:
name: unattended-upgrades
state: present
- name: Enable periodic package-list refresh and unattended upgrades
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";
- name: Enable unattended-upgrades service
ansible.builtin.service:
name: unattended-upgrades
enabled: true
state: started
+222
View File
@@ -0,0 +1,222 @@
---
- 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