From 2020 to 2023 I ran a map of everyone who tried to guess the password on my Raspberry Pi. Between January 2022 and February 2023 it logged 166,272 failed logins from 19,187 IP addresses, about 600 a day on the 271 days it was running. Its regex only matched logins for accounts that didn't exist, so it never counted a single guess at root. It's still the project I'm most proud of.
Why build it
I self-hosted things on a Pi in university, which meant leaving SSH open. SSH is how you log into a server remotely: username, password, and you get a terminal. Leaving it open lets you fix things from anywhere. It also lets anyone on the internet try.
You don't want your server ending up in a botnet farm: it eats your home bandwidth, and your ISP won't be too happy. So before locking it down, I wanted to see who was knocking.
It took two years to draw a dot
The idea fits in one sentence: read the login log, find the failed attempts, look up where each IP address is, and draw a dot. Getting there took most of two years. My commit messages tell it better than I can:
- Nov 2020: "add node js and get ip address to lat,long in an array"
- Dec 2020: "maybe express will solve our problems", "maybe sockets would work", "flask always comes back", "maybe netcat and bash are the best way"
- Feb 2021: "my brain hurts", then six days later, "functional product"
- May 2021: a 3D globe. "filter globe data, crashes with array >10,000"
- Late 2021: moved it to AWS Lambda, and paid for it
- Jun 2022: "overwrite js version with rust"
Version one was grep. Every failed login is a line in /var/log/auth.log, so this prints a stream of attacker IPs (shown with documentation addresses instead of real ones):
tail -Fn0 /var/log/auth.log | grep --line-buffered "Failed password for" | grep --line-buffered -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}'
203.0.113.42
203.0.113.42
198.51.100.7
192.0.2.19
An IP doesn't come with a location, so each one went to a lookup API for a rough latitude and longitude, and geopandas put them on a map:
Then I spent about 30 hours, during finals of my second-to-last semester, getting rsyslog (the program Linux uses to route log lines) to send every failed login over HTTP to a small web service. It kept failing with missing-module errors, so in the meantime I settled for forwarding over plain TCP:
The actual problem: omhttp has to be enabled when rsyslog is compiled, and Ubuntu's package didn't have it. So I compiled it from source. When it finally worked, I realized I didn't need it: I was sending logs over the network to a service on the same machine, when I could have just read the damn log file. I kept building on it anyway, because starting over was hard pre-AI.
The last version is a small Rust program that tails the log, matches each line with a regex, and stores the attacker in Redis. The web page showed everyone from the last 5 hours.
static ref RE: Regex = Regex::new(r"(\w{0,9}\s+\d{1,2} \d{2}:\d{2}:\d{2}) localhost sshd\[\d*]: Failed password for invalid user (\w{0,12}) from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}).*").unwrap();
The Rust version in May 2022: hover a dot and you get the username it tried. The log behind it has a user=root line, one of the guesses the map never counted.
Two side quests worth remembering: my deploy script opened the app on a new port every time it ran, and cross-compiling Rust from an M1 Mac to the Pi 3B's 32-bit ARM needed a different C compiler for one dependency. A Pi 4 on 64-bit ARM came later.
What it caught
It was bots scanning the whole internet: cybersecurity companies' scanners, scanners from China (hard to tell much more through China's firewall), and a couple of US students scanning from AWS accounts.
Bots don't keep office hours. On an average day, the busiest hour (midnight, server time) got 28 attempts and the quietest (8 a.m.) got 23. The worst single day was December 11, 2022, with 3,010.
They tried 23,725 different usernames. The most common say a lot about what they expect to find:
admin(15,848): the default on routers and a lot of softwaretest(5,224) anduser(4,788): accounts people forget to deleteoracle(1,933),git(1,176),mysql(815): default accounts for databases and Git serversubnt(818): Ubiquiti's default, which I now run, lolpi(812): the Raspberry Pi default, which is what this was
Bots also tried edwin, my name, 33 times. That wasn't an account on the server, but seeing it in there was scary.
The whole table is small enough to query in your browser. It's all 166,272 rows, with each IP address swapped for a number (ip_id) and a country looked up from where its dot was drawn.
The blind spot
SSH writes two different lines for a wrong password. If the account doesn't exist, it says "invalid user". If it does exist, it doesn't:
Jan 12 03:14:07 localhost sshd[2231]: Failed password for invalid user admin from 203.0.113.42 port 52144 ssh2
Jan 12 03:14:09 localhost sshd[2233]: Failed password for root from 198.51.100.7 port 40112 ssh2
Jan 12 03:14:12 localhost sshd[2235]: Failed password for invalid user deploy-bot from 192.0.2.19 port 33018 ssh2
My regex required "invalid user". So the map counted every guess at accounts that didn't exist and skipped every guess at the ones that did, like root. From the backup, with DuckDB:
SELECT count(*) AS root_attempts FROM ssh_failed WHERE "user" = 'root';
root_attempts = 0
Zero, out of 166,272. The second bug is smaller: \w{0,12} drops any username over 12 characters or with a dash, like deploy-bot. The fix is one optional group:
old = re.compile(r"... Failed password for invalid user (\w{0,12}) from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})")
new = re.compile(r"Failed password for (?:invalid user )?(\S+) from (\S+)")
old: admin 203.0.113.42 new: admin 203.0.113.42
old: (no match) new: root 198.51.100.7
old: (no match) new: deploy-bot 192.0.2.19
How it ended
The real fix was turning off password logins, since keys are more secure. I thought about running a honeypot, a fake server that lets attackers in so you can watch them, but running one at home seemed like a good way to piss off some bad actors.
Turning off passwords also ended the map. No passwords, no "Failed password" lines, no data. There's also a gap from January to May 2022 where I think it just broke, lol. Before retiring it I moved the data to Postgres and showed it in Grafana instead.
Looking back (2026)
Before this, my projects had scopes someone else laid out. This was the first time I decided what to build and what not to build, and that's the skill I use most now. With AI it matters more: you can easily have an agent spend a billion tokens on something absolutely stupid. It also taught me to read terse documentation, which turns out to be a lot of my job.
Links
- pewpew, the attack-map look I was going for
- Geolocating SSH hackers in real time
- ssh-log-to-influx
- Cowrie, the honeypot I didn't run
- Parsing logs 230x faster with Rust

