LY

SSH Hardening & Security Audit

security

Aug 26, 2026

SSH Hardening & Security Audit

Both servers were running stock SSH configs. Not terrible, but not locked down either. The goal was to eliminate password auth entirely, restrict which users can log in, rate-limit connection attempts, and close every port that doesn't need to be open. Two different operating systems meant two different approaches to the same problem.

1. server1 (OpenBSD) — SSH config

OpenBSD ships with a conservative sshd by default, but I tightened it further. In /etc/ssh/sshd_config: PasswordAuthentication no, PermitRootLogin no, AllowUsers sysadmin (only one user can SSH in), MaxAuthTries 3, ClientAliveInterval 300 (drop idle connections after 5 minutes), and all forwarding disabled — AllowTcpForwarding no, AllowAgentForwarding no, X11Forwarding no.

2. server1 — pf rate limiting

OpenBSD's pf firewall handles connection-level rate limiting. I added a <bruteforce> table and a rule that allows SSH connections with max-src-conn-rate 5/60 — if any IP opens more than 5 connections in 60 seconds, it gets added to the bruteforce table and all further connections are dropped. The table is persistent across reboots.

The pf rules also explicitly block mail ports (25, 587, 993) since server1 no longer runs mail services. Only ports 80 and 443 are open for web traffic, plus 53 for DNS.

3. server1 — the lockout incident

During initial testing of the pf rate limiting, I hit a problem. The daily backup script from server2 opens multiple SSH connections to server1 in quick succession — rsync, status checks, config pulls. This triggered the rate limiter, and server2's IP got added to the bruteforce table. server2 could no longer reach server1 at all.

I had to access server1 through the cloud provider's web console (out-of-band management), reboot the machine to flush the pf tables, and then fix the config. The solution was adding server2's IP to a whitelist that gets evaluated before the rate limit rule. Order matters in pf — rules are evaluated first-to-last, and the first match wins.

4. server2 (Debian) — SSH config

Same sshd hardening as server1: key-only auth, no root login, AllowUsers user, MaxAuthTries 3, ClientAliveInterval 300. The difference is in the forwarding: server2 allows TCP forwarding because Docker needs it for container networking. Everything else is disabled.

5. server2 — ufw + fail2ban

Debian doesn't have pf, so rate limiting uses two layers. ufw with LIMIT on port 22 handles basic connection throttling — it drops connections from IPs that attempt more than 6 connections in 30 seconds. On top of that, fail2ban watches /var/log/auth.log for failed login attempts and bans offending IPs for 10 minutes after 5 failures.

The fail2ban stats tell the story: 435 total bans since installation, with 2 IPs currently blocked at any given time. Most are automated scanners hitting port 22 and trying common usernames. They get exactly 5 attempts before they're firewalled.

6. Docker non-root hardening

While auditing the security posture, I noticed Uptime Kuma was running as root inside its container. Changed the docker-compose to run as user 1000:1000. This means even if someone exploits a vulnerability in the monitoring service, they can't escalate to root inside the container, and container breakout gives them an unprivileged user on the host.

7. Automated config backups

Created ~/bin/backup-configs that runs daily at 2 AM via cron. It copies SSH configs, pf rules (server1), ufw rules (server2), Caddy config, NSD zone files, and cron tabs to a timestamped directory. Retention is 30 days — older backups get deleted automatically. If a config change breaks something, I can diff against yesterday's backup and roll back.

8. Automatic security patches

server1 runs /usr/local/bin/security-updates weekly on Monday at 3 AM. It applies syspatch (OpenBSD binary patches) and runs pkg_add -u (package updates). server2 has unattended-upgrades configured for security-only updates. Both log their output for review.

9. File integrity monitoring

AIDE (Advanced Intrusion Detection Environment) is set up on server2. It generates a baseline database of file hashes for critical system directories — /etc, /usr/bin, /usr/sbin. Daily checks compare current state against the baseline and report any unexpected changes. This catches unauthorized modifications to system binaries or configs.

10. Firewall audit results

Final port inventory after hardening. server1: 22 (SSH, rate-limited), 53 (DNS), 80/443 (web). Mail ports 25, 587, 993 explicitly blocked. server2: 22 (SSH, ufw LIMIT + fail2ban), 80/443 (web, Caddy reverse proxy), Docker bridge ports for internal container communication only. Both servers — no unnecessary services listening, no world-accessible management ports, no password auth anywhere.

11. What's not included

I didn't set up intrusion detection on server1 (OpenBSD has its own integrity checking via mtree, which I haven't configured yet). I also didn't implement SSH certificate auth — key-based is sufficient for two servers with one admin each. Port knocking was considered and rejected as unnecessary complexity given the rate limiting already in place.

← all posts