SSH hardening playbook walkthrough¶
This is the practical "what does the ssh-hardening Ansible playbook actually do, line by line" companion to SSH Server Hardening. It walks through the directives the playbook drops into /etc/ssh/sshd_config.d/00-hardening.conf, explains why each one matters, and shows how to verify on a running host.
The playbook itself is at src/msai_setup/lab/ansible/playbooks/ssh-hardening.yml and is idempotent — running it twice is a no-op.
What the playbook produces¶
A single drop-in at /etc/ssh/sshd_config.d/00-hardening.conf, loaded after the distro's sshd_config (which has Include /etc/ssh/sshd_config.d/*.conf). The handler reloads sshd only if the file changed, and a pre-reload sshd -t check refuses to apply a config that wouldn't parse.
Order matters: the playbook depends on your public key already being authorised (
bootstrap.ymlputs it there during provisioning). Disabling password auth before keys are in place is how you lock yourself out.
The directives¶
Connectivity baseline¶
Port 22 is intentional — moving SSH to a non-standard port is security theatre on a private network. If you must hide from internet scanners, front sshd with WireGuard / Tailscale instead. AddressFamily any lets the server listen on both IPv4 and IPv6; restrict only if you have a specific reason.
Authentication policy¶
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
UsePAM yes
MaxAuthTries 3
LoginGraceTime 60
The four loud-and-clear rules:
- No root over SSH. Use
sudofrom an unprivileged account. - No password auth. Keys only. Eliminates the entire "brute force weak password" attack class.
- No keyboard-interactive / PAM password prompts sneaking back in.
KbdInteractiveAuthenticationandChallengeResponseAuthenticationare both off because PAM can offer password-style prompts via either unless you nail them down. MaxAuthTries 3caps per-connection auth attempts. Combined withLoginGraceTime 60(drop the connection if it doesn't authenticate in 60 s), this makes scripted attempts expensive.
UsePAM yes is left enabled because account/session PAM hooks (e.g. pam_limits, audit logging, pam_motd) still need to run.
Reduce what sshd offers¶
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
AllowStreamLocalForwarding no
PermitTunnel no
If you don't use a feature, it shouldn't be available — there are known exploits (CVE-2023-38408 was an agent-forwarding RCE) that only work if the corresponding switch is on.
Turn one back on per-user via Match if you actually need it:
Session limits¶
ClientAliveInterval 300+ClientAliveCountMax 2drops dead connections after ~10 minutes of silence. Without this, an abandoned laptop keeps your session pinned forever.MaxSessions 2caps multiplexed sessions per connection — limits blast radius from a compromised client.MaxStartups 10:30:60: at 10 pre-auth connections, start dropping 30% of new ones; reject all once we hit 60. This is the cheap defence against connection-flood DoS.LogLevel VERBOSEwrites the fingerprint of the key used on every successful login. Indispensable for "which key actually let this in?" forensics.
Modern crypto¶
KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256
PubkeyAcceptedAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256
Three things going on:
- Post-quantum-hybrid KEX:
sntrup761x25519-sha512@openssh.comcombines classical X25519 with a lattice-based KEM so traffic that's recorded today can't be decrypted by a future quantum attacker. Both endpoints must support it; modern OpenSSH does. - AEAD ciphers only: chacha20-poly1305 and AES-GCM. No CBC-mode, no plain CTR — those are vulnerable to padding-oracle / MAC-tag forgery in adversarial conditions.
- ETM MACs: encrypt-then-MAC is the correct order. The
-etm@suffix isn't decorative; without it sshd would default to encrypt-and-MAC for some algorithms.
The *Algorithms lines drop ssh-rsa with SHA1, DSA, ssh-rsa-cert-v01 (SHA1 cert chain), and anything else CIS-style scanners flag.
Verify the result¶
After running the playbook, query the effective sshd config (not just the drop-in file — sshd -T shows the merged final state):
sudo sshd -T 2>/dev/null | grep -iE \
'^(permitroot|password|pubkey|kbd|maxauth|clientalive|allow(tcp|agent)forwarding|x11|ciphers|macs|kexalgorithms)' \
| sort
Expected output on a lab VM after msai lab apply ssh-hardening:
allowagentforwarding no
allowtcpforwarding no
ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
clientalivecountmax 2
clientaliveinterval 300
kbdinteractiveauthentication no
kexalgorithms sntrup761x25519-sha512@openssh.com,...
macs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
maxauthtries 3
passwordauthentication no
permitrootlogin no
pubkeyacceptedalgorithms ssh-ed25519,...,rsa-sha2-512,rsa-sha2-256
pubkeyauthentication yes
x11forwarding no
Sanity checks from the client¶
Try a password auth attempt — it should fail before sshd asks you:
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no morten@<host>
# expected: "Permission denied (publickey)."
Confirm root login is blocked even with the right key:
Confirm forwarding is disabled:
ssh -L 9999:localhost:11434 morten@<host>
# expected: "channel 0: open failed: administratively prohibited: open failed"
Run the audit tool¶
ssh-audit cross-checks against a known-good policy and flags anything you missed:
You should see all green; if you don't, treat the warnings as a TODO.
Applying the same playbook to the real box¶
The playbook is hardware-agnostic — same file runs against the lab VM and against the MS-S1 MAX itself. Two prerequisites:
- Your public key is already in
/home/morten/.ssh/authorized_keyson the target. The provisioner handles this for the lab; on a fresh MS-S1 MAX install, copy it in manually first viassh-copy-idwhile password auth is still enabled. - You can reach the box from your laptop on port 22 (no firewall between you).
Then:
Where prod-inventory.yml points lab (or whatever group your real host is in) at the production IP/hostname.
Re-running and updating¶
The playbook is idempotent — running it a second time produces no diff. If you edit the directive list in the playbook, the next run produces a precise diff (what changed, what stayed), and sshd -T / the audit tool confirm the new effective state.
If you ever need to roll a change back: delete /etc/ssh/sshd_config.d/00-hardening.conf and reload sshd. The distro defaults take over.
See also¶
- SSH Server Hardening — the full reference, including fail2ban / 2FA / chroot-SFTP options the lab playbook intentionally does not enable
- SSH Configuration — every sshd_config directive
src/msai_setup/lab/ansible/playbooks/ssh-hardening.yml— the authoritative source