From 3d08c8f1ff102526a66aa191403525219359b240 Mon Sep 17 00:00:00 2001 From: gasaichandesu Date: Tue, 29 Sep 2026 00:06:45 +0400 Subject: [PATCH] Add reusable Debian and Ubuntu hardening playbooks --- .gitignore | 25 +++ README.md | 41 ++++ ansible.cfg | 10 + docs/hardening.md | 70 +++++++ docs/plan.md | 47 +++++ inventory/group_vars/all.yml.example | 36 ++++ inventory/group_vars/all/vault.yml.example | 4 + inventory/host_vars/server1.yml.example | 12 ++ inventory/hosts.yml.example | 9 + playbooks/bootstrap-admin.yml | 23 +++ playbooks/discover.yml | 32 +++ playbooks/harden.yml | 52 +++++ requirements-controller.txt | 2 + requirements.yml | 8 + roles/admin_user/tasks/main.yml | 44 ++++ roles/baseline/tasks/main.yml | 26 +++ roles/hardening/tasks/main.yml | 222 +++++++++++++++++++++ 17 files changed, 663 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ansible.cfg create mode 100644 docs/hardening.md create mode 100644 docs/plan.md create mode 100644 inventory/group_vars/all.yml.example create mode 100644 inventory/group_vars/all/vault.yml.example create mode 100644 inventory/host_vars/server1.yml.example create mode 100644 inventory/hosts.yml.example create mode 100644 playbooks/bootstrap-admin.yml create mode 100644 playbooks/discover.yml create mode 100644 playbooks/harden.yml create mode 100644 requirements-controller.txt create mode 100644 requirements.yml create mode 100644 roles/admin_user/tasks/main.yml create mode 100644 roles/baseline/tasks/main.yml create mode 100644 roles/hardening/tasks/main.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7164a7 --- /dev/null +++ b/.gitignore @@ -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] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f4b860 --- /dev/null +++ b/README.md @@ -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/.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 ` 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//`: 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. diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..45ce43a --- /dev/null +++ b/ansible.cfg @@ -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 diff --git a/docs/hardening.md b/docs/hardening.md new file mode 100644 index 0000000..06a0551 --- /dev/null +++ b/docs/hardening.md @@ -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/.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 `. + +## 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. diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..2655c22 --- /dev/null +++ b/docs/plan.md @@ -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//` 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. diff --git a/inventory/group_vars/all.yml.example b/inventory/group_vars/all.yml.example new file mode 100644 index 0000000..8967b7c --- /dev/null +++ b/inventory/group_vars/all.yml.example @@ -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: [] diff --git a/inventory/group_vars/all/vault.yml.example b/inventory/group_vars/all/vault.yml.example new file mode 100644 index 0000000..abaf0d5 --- /dev/null +++ b/inventory/group_vars/all/vault.yml.example @@ -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" diff --git a/inventory/host_vars/server1.yml.example b/inventory/host_vars/server1.yml.example new file mode 100644 index 0000000..a2a5cb6 --- /dev/null +++ b/inventory/host_vars/server1.yml.example @@ -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: [] diff --git a/inventory/hosts.yml.example b/inventory/hosts.yml.example new file mode 100644 index 0000000..1738638 --- /dev/null +++ b/inventory/hosts.yml.example @@ -0,0 +1,9 @@ +--- +all: + children: + debian_servers: + hosts: + server1: + ansible_host: 192.0.2.10 + ansible_user: existing_admin + ansible_port: 22 diff --git a/playbooks/bootstrap-admin.yml b/playbooks/bootstrap-admin.yml new file mode 100644 index 0000000..da88e1e --- /dev/null +++ b/playbooks/bootstrap-admin.yml @@ -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 diff --git a/playbooks/discover.yml b/playbooks/discover.yml new file mode 100644 index 0000000..eb0ad11 --- /dev/null +++ b/playbooks/discover.yml @@ -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 }}" diff --git a/playbooks/harden.yml b/playbooks/harden.yml new file mode 100644 index 0000000..16b6f63 --- /dev/null +++ b/playbooks/harden.yml @@ -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 diff --git a/requirements-controller.txt b/requirements-controller.txt new file mode 100644 index 0000000..c516e06 --- /dev/null +++ b/requirements-controller.txt @@ -0,0 +1,2 @@ +# Pin the supported control-node runtime for reproducible playbook execution. +ansible-core==2.21.4 diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..6575367 --- /dev/null +++ b/requirements.yml @@ -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" diff --git a/roles/admin_user/tasks/main.yml b/roles/admin_user/tasks/main.yml new file mode 100644 index 0000000..469fa1a --- /dev/null +++ b/roles/admin_user/tasks/main.yml @@ -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 }}" diff --git a/roles/baseline/tasks/main.yml b/roles/baseline/tasks/main.yml new file mode 100644 index 0000000..e15bce7 --- /dev/null +++ b/roles/baseline/tasks/main.yml @@ -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 diff --git a/roles/hardening/tasks/main.yml b/roles/hardening/tasks/main.yml new file mode 100644 index 0000000..641839a --- /dev/null +++ b/roles/hardening/tasks/main.yml @@ -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