Add 4 articles: nginx/apache bad-request jails, SSH fleet hardening, Watchtower localhost relay

All sourced from 2026-04-17 work sessions:
- fail2ban-nginx-bad-request-jail: enable stock jail (just needs wiring)
- fail2ban-apache-bad-request-jail: custom filter from scratch, no stock equivalent
- ssh-hardening-ansible-fleet: drop-in approach with Fedora/Ubuntu edge cases
- watchtower-smtp-localhost-relay: credential-free localhost postfix relay pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 21:06:06 -04:00
parent 9c1a8c95d5
commit 961ce75b88
4 changed files with 459 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
---
title: "SSH Hardening Fleet-Wide with Ansible"
domain: selfhosting
category: security
tags: [ssh, ansible, security, hardening, fleet]
status: published
created: 2026-04-17
updated: 2026-04-17
---
# SSH Hardening Fleet-Wide with Ansible
## Overview
Default SSH daemon settings on both Ubuntu and Fedora/RHEL are permissive. A drop-in configuration file (`/etc/ssh/sshd_config.d/99-hardening.conf`) lets you tighten settings without touching the distro-managed base config — and Ansible can deploy it atomically across every fleet host with a single playbook run.
## Settings to Change
| Setting | Default | Hardened | Reason |
|---|---|---|---|
| `PermitRootLogin` | `yes` | `without-password` | Prevent password-based root login; key auth still works for Ansible |
| `X11Forwarding` | `yes` | `no` | Nothing in a typical homelab fleet uses X11 tunneling |
| `AllowTcpForwarding` | `yes` | `no` | Eliminates a tunneling vector if a service account is compromised |
| `MaxAuthTries` | `6` | `3` | Cuts per-connection brute-force attempts in half |
| `LoginGraceTime` | `120` | `30` | Reduces the window for slow-connect attacks |
## The Drop-in Approach
Rather than editing `/etc/ssh/sshd_config` directly (which may be managed by the distro or overwritten on upgrades), place overrides in `/etc/ssh/sshd_config.d/99-hardening.conf`. The `Include /etc/ssh/sshd_config.d/*.conf` directive in the base config loads these in alphabetical order, and **first match wins** — so `99-` ensures your overrides come last and take precedence.
> **Fedora/RHEL gotcha:** Fedora ships `/etc/ssh/sshd_config.d/50-redhat.conf` which sets `X11Forwarding yes`. Because first-match-wins applies, `50-redhat.conf` loads before `99-hardening.conf` and wins. You must patch `50-redhat.conf` in-place before deploying your drop-in, or the X11Forwarding setting will be silently ignored.
## Ansible Playbook
```yaml
- name: Harden SSH daemon fleet-wide
hosts: all:!raspbian
become: true
gather_facts: true
tasks:
- name: Ensure sshd_config.d directory exists
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
owner: root
group: root
mode: '0755'
- name: Ensure Include directive is present in sshd_config
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
line: "Include /etc/ssh/sshd_config.d/*.conf"
insertbefore: BOF
state: present
# Fedora only: neutralize 50-redhat.conf's X11Forwarding yes
# (first-match-wins means it would override our 99- drop-in)
- name: Comment out X11Forwarding in 50-redhat.conf (Fedora)
ansible.builtin.replace:
path: /etc/ssh/sshd_config.d/50-redhat.conf
regexp: '^(X11Forwarding yes)'
replace: '# \1 # disabled by ansible hardening'
when: ansible_os_family == "RedHat"
ignore_errors: true
- name: Deploy SSH hardening drop-in
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/99-hardening.conf
content: |
# Managed by Ansible — do not edit manually
PermitRootLogin without-password
X11Forwarding no
AllowTcpForwarding no
MaxAuthTries 3
LoginGraceTime 30
owner: root
group: root
mode: '0644'
notify: Reload sshd
- name: Verify effective SSH settings
ansible.builtin.command:
cmd: sshd -T
register: sshd_effective
changed_when: false
- name: Assert hardened settings are active
ansible.builtin.assert:
that:
- "'permitrootlogin without-password' in sshd_effective.stdout"
- "'x11forwarding no' in sshd_effective.stdout"
- "'allowtcpforwarding no' in sshd_effective.stdout"
- "'maxauthtries 3' in sshd_effective.stdout"
- "'logingracetime 30' in sshd_effective.stdout"
fail_msg: "One or more SSH hardening settings not effective — check for conflicting config"
when: not ansible_check_mode
handlers:
- name: Reload sshd
ansible.builtin.service:
# Ubuntu/Debian: 'ssh' | Fedora/RHEL: 'sshd'
name: "{{ 'ssh' if ansible_os_family == 'Debian' else 'sshd' }}"
state: reloaded
```
## Edge Cases
**Ubuntu vs Fedora service name:** The SSH daemon is `ssh` on Debian/Ubuntu and `sshd` on Fedora/RHEL. The handler uses `ansible_os_family` to pick the right name automatically.
**Missing Include directive:** Some minimal installs don't have `Include /etc/ssh/sshd_config.d/*.conf` in their base config. The `lineinfile` task adds it if absent. Without this, the drop-in directory exists but is never loaded.
**Fedora's 50-redhat.conf:** Sets `X11Forwarding yes` with first-match priority. The playbook patches it before deploying the drop-in.
**`sshd -T` in check mode:** `sshd -T` reads the *current* running config, not the pending changes. The assert task is guarded with `when: not ansible_check_mode` to prevent false failures during dry runs.
**PermitRootLogin on hosts that already had it set:** Some hosts (e.g., those managed by another tool) may already have `PermitRootLogin without-password` set elsewhere. The drop-in still applies cleanly — it just becomes a no-op for that setting.
## Verify Manually
```bash
# Check effective settings on any host
ssh root@<host> "sshd -T | grep -E 'permitrootlogin|x11forwarding|allowtcpforwarding|maxauthtries|logingracetime'"
# Expected:
# permitrootlogin without-password
# x11forwarding no
# allowtcpforwarding no
# maxauthtries 3
# logingracetime 30
```
## See Also
- [linux-server-hardening-checklist](linux-server-hardening-checklist.md)
- [ansible-unattended-upgrades-fleet](ansible-unattended-upgrades-fleet.md)
- [ufw-firewall-management](ufw-firewall-management.md)