common: SSH hardening¶
Problem¶
A freshly installed Debian allows password login over SSH by default, and depending on
the install path, sometimes root login too. For a server reachable from the internet, or
even just from the clinic LAN, that's an open door for brute-force attacks. This role
hardens SSH to key-only access with restricted root login, without hand-maintaining
every line of /etc/ssh/sshd_config.
Variables¶
All variables are prefixed common_ssh_* and have sensible defaults in
ansible/roles/common/defaults/main.yml.
| Variable | Default | Meaning |
|---|---|---|
common_ssh_port |
22 |
SSH port. The ufw role opens this port automatically (see common-ufw.md) - changing it only needs common_ssh_port set once, centrally |
common_ssh_permit_root_login |
"no" |
Root login off entirely |
common_ssh_password_authentication |
"no" |
Password login off, key only |
common_ssh_pubkey_authentication |
"yes" |
Key login on |
common_ssh_kbd_interactive_authentication |
"no" |
Interactive auth methods off |
common_ssh_x11_forwarding |
"no" |
No X11 forwarding |
common_ssh_max_auth_tries |
3 |
Max. auth attempts per connection |
common_ssh_login_grace_time |
30 |
Seconds until the connection drops without successful auth |
common_ssh_allow_users |
[] |
Empty = no restriction. Only set once you're sure your own user is in the list |
common_ssh_allow_groups |
[] |
Same as above, group-based |
common_ssh_preflight_enabled |
true |
Safety check before disabling root login (see below). Only set to false for deliberately root-only hosts (e.g. throwaway CI images) |
common_ssh_tunnel_users |
[] |
List of {name, public_key, targets} - shell-less users restricted to exactly the loopback port-forwards listed in targets, e.g. 127.0.0.1:3000 for a Grafana dashboard. See below |
Someone who only needs to see a dashboard (Grafana, Prometheus) doesn't need a shell on a
server that processes patient data - that was the strongest argument against a plain SSH
tunnel as the access model (see
ADR 0003).
common_ssh_tunnel_users closes that gap:
common_ssh_tunnel_users:
- name: viewer
public_key: "ssh-ed25519 AAAA... viewer@laptop"
targets:
- "127.0.0.1:3000"
The account gets /usr/sbin/nologin as its shell and no password; sshd itself enforces
the restriction to targets via a per-user Match block (PermitOpen, ForceCommand
nologin) - no separate proxy or component. Multiple targets need multiple entries in the
targets list, one PermitOpen line each.
What gets changed¶
- File:
/etc/ssh/sshd_config.d/10-linumed-hardening.conf(newly created). The main file/etc/ssh/sshd_configis not touched - it's ucf-managed on Debian, and a full-file template would lose against ucf on everyopenssh-serverupgrade. - Service:
ssh.serviceis reloaded on change (systemctl reload ssh), not restarted - existing connections stay open. - Port: unchanged by default (22).
Verification¶
Check for yourself after a playbook run, don't trust the playbook's own output:
sudo /usr/sbin/sshd -T | grep -E '^(permitrootlogin|passwordauthentication|port|maxauthtries)'
Expected output (with default values):
permitrootlogin no
passwordauthentication no
port 22
maxauthtries 3
Additionally: a password login must fail, a key login must succeed.
Pitfalls¶
- Drop-in order: sshd uses the first value it finds for each directive. Cloud
images often ship a
50-cloud-init.conf- our10-linumed-hardening.confonly wins because10sorts before50. Any additional drop-ins of your own need a higher number, never a lower one. - Socket activation: if
ssh.socketis active (Debian offers this optionally, seeopenssh-server'sREADME.Debian),sshdignores its ownPortdirective - the port then comes from the socket unit'sListenStream=. The role detects this and, since #15, templates its own drop-in (/etc/systemd/system/ssh.socket.d/listen.conf) instead of aborting - the unit ships withListenStream=22hardwired, so the drop-in has to blank that out before setting the actual port, orsshdends up listening on both ports at once.ssh.serviceis stopped before the socket unit restarts: withAccept=no, the service runs continuously holding the socket unit's listening fd, and restarting the socket unit fails while the service is still active ("Socket service ssh.service already active, refusing."). Ifcommon_ssh_portis set back to22, the role removes the drop-in again on the next run. - Root login lockout without a safety net: the role aborts on its own if no
non-root user with sudo rights and a deployed SSH key exists - that's intentional, not
a bug. So create an admin user with a key before the first run - on a fresh minimal
install,
scripts/bootstrap.shtakes care of that. - Tunnel users' drop-in has to be the last one parsed: a
Matchblock scopes every directive parsed after it - across files, sinceIncludesplicessshd_config.d/*.confin inline - until the nextMatchline or the end of the config. The tunnel-users drop-in is deployed as90-linumed-tunnel-users.conf, deliberately higher than every other number in this role (and higher than a cloud image's own50-cloud-init.conf). If you ever add your ownsshd_config.ddrop-in with a still higher number, its directives would silently end up scoped to only the last tunnel user'sMatchblock.