The first time you check the logs on a freshly deployed server, it's a little jarring. You put a box on the internet maybe an hour ago and there are already hundreds of failed SSH login attempts. Nobody knows your server exists. Nobody cares. The bots found it anyway, because they weren't looking for your server. They're scanning every IP address, all the time, forever.
This isn't a targeted attack. It's the background radiation of the internet. Automated scripts cycling through default usernames and common passwords on every open port 22 they can find. Most of them come from a handful of countries, run on compromised machines or cheap VPS instances, and they never stop.
This guide covers how to catch them, ban them, figure out where they're coming from, and put that data on your website so you can watch it happen live. Which is honestly more entertaining than it sounds.
Fail2ban watches your logs and bans IPs that keep failing authentication. It's been around forever because it works.
sudo apt update && sudo apt install fail2ban -y
One thing about Debian 12 that trips people up: it uses the systemd journal by
default instead of writing to /var/log/auth.log. If you install
fail2ban and it doesn't seem to be catching anything, this is probably why. The
default backend looks for log files that don't exist.
The fix is to tell fail2ban to use the systemd backend. Create a local config
override (never edit the files in /etc/fail2ban/jail.d/ directly, they
get overwritten on updates):
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
Then edit /etc/fail2ban/jail.local and set the backend:
[DEFAULT]
backend = systemd
The sshd jail is the one you care about. Still in jail.local:
[sshd]
enabled = true
port = ssh
filter = sshd
maxretry = 3
findtime = 600
bantime = 3600
This bans any IP that fails three times in ten minutes, for one hour. Which is fine as a starting point, but the bots don't learn. They come back. So you want incremental bans: each repeat offense gets a longer timeout.
[DEFAULT]
bantime.increment = true
bantime.factor = 24
bantime.maxtime = 4w
bantime.overalljails = true
With this, a first ban is one hour. If they come back, it goes up. The factor of 24
means the second ban is 24 hours, and it keeps climbing up to a maximum of four
weeks. overalljails means repeat offenders get punished across all jails,
not just the one they tripped.
Restart fail2ban and check it's running:
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
You should see the jail listed as active with the filter picking up connections from the systemd journal.
Knowing that 218.92.0.107 got banned is useful. Knowing it came from
China makes the data a lot more interesting to look at. GeoIP databases map IP
addresses to countries:
sudo apt install geoip-bin geoip-database -y
Now you can look up any IP:
$ geoiplookup 218.92.0.107
GeoIP Country Edition: CN, China
The free GeoIP database isn't perfect. Some IPs come back as "Unknown." VPS provider IPs sometimes register as the wrong country. But for aggregate stats and a "where are the brute-force attempts coming from" overview, it's more than good enough.
The idea is simple: a script reads the fail2ban log, runs geoiplookup on each banned IP, and writes a JSON file with the results. A cron job runs it every minute. The website fetches the JSON and displays it.
The script is written in Python because it handles JSON natively and the only external
calls are subprocess calls to geoiplookup and
fail2ban-client. No pip dependencies. The key bits:
Parsing the log for ban events:
BAN_RE = re.compile(
r"^(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}),\d+\s+"
r"fail2ban\.actions\s+\[\d+\]:\s+NOTICE\s+"
r"\[sshd\]\s+Ban\s+(\S+)"
)
Getting the currently banned IPs (not just historical bans):
result = subprocess.run(
["fail2ban-client", "banned"],
capture_output=True, text=True, timeout=10
)
ips = re.findall(r"(\d+\.\d+\.\d+\.\d+)", result.stdout)
GeoIP lookups are cached per IP so the same address doesn't get looked up twice. The JSON output includes a summary, the last 50 ban events, top countries, and the list of currently banned IPs. The write is atomic (write to a temp file, then rename) so the frontend never reads a half-written file.
The script also handles log rotation. It reads both
/var/log/fail2ban.log and .log.1, so a logrotate cycle
doesn't wipe the recent history.
The website just fetches the JSON file and renders it. No websockets, no server-sent events, no build tools. A static JSON file that updates every minute and a vanilla JS script that polls it.
Why not websockets? Because this data updates once a minute, not once a second. Websockets add server-side state, connection management, reconnection logic, and a reason for nginx to hold open persistent connections. For data that changes every 60 seconds, polling a static file is simpler, cheaper, and more resilient. If the server goes down, the frontend just shows the last good data until it comes back.
The fetch runs on page load and then every 60 seconds via setInterval.
If it fails, it doesn't crash the page or show an error modal. It just quietly
keeps the last good data in place.
The majority of brute-force attempts come from a small number of countries. China, the United States, and a rotating cast of others consistently top the list. This doesn't mean people in those countries are personally attacking you. It means those countries have a lot of compromised machines and a lot of cheap VPS providers who don't police their customers.
You'll see the same IP ranges come back repeatedly. These are scanning farms, likely cycling through enormous IP lists. Some of them are remarkably persistent. Others try once, get banned, and never return.
A surprising number of source IPs belong to cloud providers. Hetzner, DigitalOcean, OVH, Alibaba Cloud. Which makes sense: if you're running a botnet, you want cheap compute with decent bandwidth, and you don't care if the account gets suspended because you'll spin up another one tomorrow.
None of this is cause for alarm. It's just the internet being the internet. The important thing is that fail2ban catches them before they can try more than a handful of passwords, and the incremental bans mean repeat offenders get locked out for weeks.
Brute-force attempts are the first thing to deal with. The second is making sure your web server isn't leaking information or missing security headers. Harden Your Web Server covers the nginx side: CSP headers, HSTS, hiding your server version, and blocking access to files that shouldn't be public.