majorwiki/02-selfhosting/security/ssh-hardening-ansible-fleet.md
Marcus Summers 06a794316b docs: point Ansible references at the new roles (clamav/ssh_hardening/tailscale)
Operational/how-to references updated to the role entry playbooks after the
ADR-0001 migration. Historical incident narrative (dated callouts, commit
refs) preserved.

- clamav-fleet-deployment: override + re-run -> clamav.yml; role note
- ssh-hardening-ansible-fleet: note this is now the ssh_hardening role
- vps-migration-baseline-checklist: table -> clamav.yml / ssh_hardening.yml
- ssh-socket-tailscale-race-condition: Affected Hosts + Prevention + References
  -> tailscale role tasks (network_wait/ssh_only_ubuntu/ssh_only_fedora)
- freshclam-logwatch-false-no-updates: codify refs -> clamav role
2026-06-11 11:33:42 -04:00

5.9 KiB

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

On the MajorsHouse fleet this is packaged as the ssh_hardening role (roles/ssh_hardening/) and run via ssh_hardening.yml or site.yml. The standalone playbook below is the illustrative equivalent.

- 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