# hardening/ (episode 10) The four files the episode 1 checklist promised and never delivered: keys-only SSH, a firewall that actually closes Docker's published ports, and fail2ban with an SSH jail and a Caddy jail. Everything here was measured on the demo box, not assumed. ## Order matters 1. **SSH first, with a lifeline.** Copy `sshd/hardening.conf` to `/etc/ssh/sshd_config.d/hardening.conf`, run `sshd -t`, then `systemctl reload ssh`. Open a second SSH session and confirm it works **before** you close the first one. Key auth is untouched by this change, but a typo in the file is how people lock themselves out. 2. **ufw, then the Docker fix.** Install ufw and allow only what the host itself listens on: ``` ufw default deny incoming ufw default allow outgoing ufw allow 22/tcp ufw allow 51820/udp # WireGuard of the NetBird client ufw allow in on wt0 # the mesh ufw enable ``` Do **not** allow 80/443/2222 here. Those are Docker published ports, and ufw cannot see them anyway (next point). Set a fallback before `ufw enable` in case you fat-finger a rule and there is no serial console: `nohup bash -c 'sleep 300; ufw --force disable' &` - then kill it once a fresh SSH session confirms you are still in. 3. **DOCKER-USER.** Paste the block from `ufw-after.rules` at the end of `/etc/ufw/after.rules` (and `after6.rules`), then `ufw reload`. Without it, `ufw deny incoming` is a lie: a colleague who runs `docker run -p 9999:80 ...` to debug something publishes port 9999 straight to the internet, ufw and all. Confirm with `iptables -S DOCKER-USER`. Why `--ctorigdstport` and not `--dport`: DNAT rewrites the destination port to the container's port before this chain runs, so `--dport` matches the wrong port. Measured on the box, the naive `--dport 80,443,2222` version let a `-p 9999:80` container through (its DNAT'd port is 80) and blocked git-ssh (2222 DNAT'd to 22). `--ctorigdstport` matches the published port. 4. **fail2ban last.** Copy `fail2ban/jail.local` and `fail2ban/filter.d/caddy-probes.conf` into `/etc/fail2ban/`, then `systemctl restart fail2ban`. Two jails: - `sshd`: `backend = systemd`, because Debian 13 has no `/var/log/auth.log` without rsyslog. - `caddy-probes`: reads the JSON access log and bans on repeated 401/403/404/429. Its ban must land in `DOCKER-USER`, not INPUT - a ban in INPUT never touches Caddy's forwarded traffic and the bot keeps getting 404s. Set `chain = DOCKER-USER` as a plain jail parameter, not inside `banaction[...]`, or the `action_` template's `chain="%(chain)s"` overrides it back to INPUT. ## The one operational trap `ufw reload` rewrites DOCKER-USER from `after.rules`, which flushes the chain and throws away fail2ban's ban jumps with it. After any `ufw reload`, run `systemctl restart fail2ban` to put the jumps (and the active bans, which fail2ban keeps in its own database) back. A plain `systemctl restart docker` does not have this problem: DOCKER-USER keeps both our rules and the fail2ban jumps across a Docker restart (measured).