Files
MajorWiki/02-selfhosting/security/ssh-hardening-ansible-fleet.md
MajorLinux 961ce75b88 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>
2026-04-17 21:06:09 -04:00

5.7 KiB

title, domain, category, tags, status, created, updated
title domain category tags status created updated
SSH Hardening Fleet-Wide with Ansible selfhosting security
ssh
ansible
security
hardening
fleet
published 2026-04-17 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

- 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

# 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