Lethio

The Wall of Shame

mar 2026 · security · self-hosting

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.

Installing fail2ban on Debian 12

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

Configuring the sshd jail

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.

Geoip enrichment

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 data generation script

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 frontend

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.

What the data shows

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.

Before you publish the addresses

An IP address is personal data in the EU. That's been settled since the Breyer ruling in 2016, and dynamic addresses count too. If you're somewhere else, go and check what your own rules say, because a lot of jurisdictions have landed in the same place.

Keeping them is the easy part. You need the full address to block anything, it's already in your logs, and stopping an attack is about as clean a legitimate interest as you'll find. Nobody sensible objects to that.

Publishing them is a different question, and the test isn't whether you can. It's whether you need to. Blocking an attack doesn't require showing the address to your visitors.

There's a real difference between sharing and displaying. AbuseIPDB, Spamhaus, DShield and your national CERT all take reports of attacking addresses, and they exist so other people can block the same hosts before they get hit. That's the address doing actual work. Sending your bans there is useful and proportionate, and it's the case the legitimate interest argument was written for.

A list on your homepage is not that. It doesn't help anyone block anything. It looks cool, which is a real reason, just not one that outweighs someone else's personal data.

Then there's who you're actually naming. Plenty of these hosts are compromised. The address you're about to put on the internet under a heading about shame might belong to someone whose NAS got owned two years ago and who has no idea it's out there knocking on doors.

So do both properly: report the full addresses to somewhere that acts on them, and cut them down before they hit the page. The script on this site drops the last octet at the point the JSON gets written, so the full address never leaves the server and isn't sitting in the page source either. You keep the wall. You just stop publishing somebody's address to make it.

def mask_ip(ip: str) -> str:
    """Drop the identifying tail before publishing. IPv4 keeps the /24, IPv6 the /48."""
    if ":" in ip:
        groups = [g for g in ip.split(":") if g]
        return ":".join(groups[:3]) + ":x" if len(groups) >= 3 else "x"
    octets = ip.split(".")
    if len(octets) == 4:
        return ".".join(octets[:3]) + ".x"
    return "x"

Call it where you build the JSON, not anywhere earlier. The geoip lookup needs the real address to resolve a country, your unique-IP count needs it or two machines on one /24 collapse into one, and fail2ban obviously needs it to ban anything. Masking in the browser instead is pointless: the JSON goes over the network, so the real address would still be sitting in the network tab for anyone who opened it.

The next layer

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.