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>
139 lines
5.7 KiB
Markdown
139 lines
5.7 KiB
Markdown
---
|
|
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)
|