Fail2ban
When you're running a very simple website such as this, and you're running, say, nginx and sshd, you're going to attract a fair amount of unwelcome traffic. In fact, in all likelihood, the majority of your traffic is likely to be bots looking for ways to take over your server.
If there was something really important on your server, you'd obviously not expose it to the internet. You'd have firewalls, load balancers, cache servers, etc., and only valid traffic would ever reach the server. That costs a fair amount of money compared to a simple VM, so it's likely not making much sense to do for a blog few will read.
Having this little traffic, you'll most likely be able to look at the access logs of your webserver without doing anything advanced, such as forwarding them via Logstash to Elasticsearch or similar. When that's the case, and if you're like me, all those bots will be annoying.
Here's an example of what it can look like:
85.85.183.115 - - [02/Sep/2025:02:41:42 +0000] "GET / HTTP/1.0" 200 880 "-" "-"
162.243.58.249 - - [02/Sep/2025:03:35:23 +0000] "GET /.env HTTP/1.1" 400 255 "-" "Mozilla/5.0; Keydrop.io/1.0(onlyscans.com/about);"
20.65.195.96 - - [02/Sep/2025:03:56:51 +0000] "GET / HTTP/1.1" 400 255 "-" "Mozilla/5.0 zgrab/0.x"
92.42.201.26 - - [02/Sep/2025:04:00:56 +0000] "GET http://ip-api.com/json/ HTTP/1.1" 400 255 "-" "Go-http-client/1.1"
92.42.201.26 - - [02/Sep/2025:04:00:56 +0000] "\x04\x01\x00P\xD0_p\x01\x00" 400 157 "-" "-"
92.42.201.26 - - [02/Sep/2025:04:00:56 +0000] "\x05\x01\x00" 400 157 "-" "-"
89.248.165.108 - - [02/Sep/2025:04:13:22 +0000] "\x03\x00\x00\x13\x0E\xE0\x00\x00\x00\x00\x00\x01\x00\x08\x00\x02\x00\x00\x00" 400 157 "-" "-"
35.216.189.16 - - [02/Sep/2025:04:41:52 +0000] "GET / HTTP/1.1" 200 510 "-" "Mozilla/5.0"
20.40.250.30 - - [02/Sep/2025:05:04:35 +0000] "GET /developmentserver/metadatauploader HTTP/1.1" 404 245 "-" "Mozilla/5.0 zgrab/0.x"
All of these are crap, and focusing on the last one, it turns out it's CVE-2025-31324. So whoever made (or made it look like) a call from 20.40.250.30 is trying to see if I'm running a vulnerable SAP server. That's just a waste of everyone's resources, and we'd both be better off without that traffic.
Enter fail2ban. It's easy enough to install:
apt install fail2banThat will give you a basic setup with (your distro's) defaults under /etc/fail2ban. For Debian, SSH is on by default, as can be seen in /etc/fail2ban/jail.d/defaults-debian.conf:
[sshd]
enabled = trueAdding your own jail
I'm using Nginx, so that's what this example will be about; Apache, etc., would be very similar. Having a configuration such as this:
server {
listen 443 ssl;
server_name www.tingolin.eu;
root /your/path;
index index.html;
}How can I easily block the above bot? A very simple, and reactive, way would be to create /etc/nginx/snippets/blocklist.conf:
location ~* (/developmentserver/metadatauploader) {
access_log /var/log/nginx/blocklist.log;
return 403;
}Then add an include in the original configuration like this:
server {
listen 443 ssl;
server_name www.tingolin.eu;
root /your/path;
index index.html;
include /etc/nginx/snippets/blocklist.conf;
}So, what happens here? Your server will include the file listed and in the end look like this:
server {
listen 443 ssl;
server_name www.tingolin.eu;
root /your/path;
index index.html;
location ~* (/developmentserver/metadatauploader) {
access_log /var/log/nginx/blocklist.log;
return 403;
}
}This means "Any request matching '/developmentserver/metadatauploader' will get a 403 FORBIDDEN, and the request will be logged to /var/log/nginx/blocklist.log". Note the "*~"; that's a regular expression, so it's going to match any request with such a substring in it — meaning also "/foo/developmentserver/metadatauploader/bar", which may or may not be what you want.
That doesn't really do much. The evil bot gets 403 (compared to 404 before), and the request is logged to another logfile.
Back to Fail2ban. Take the line you wanted to block and check if fail2ban can parse it as it is, without having to add your own regexp (this is a standard Nginx access_log, so you don't really need to):
fail2ban-regex '20.40.250.30 - - [02/Sep/2025:05:04:35 +0000] "GET /developmentserver/metadatauploader HTTP/1.1" 404 245 "-" "Mozilla/5.0 zgrab/0.x"' "<HOST>"
Running tests
=============
Use failregex line : <HOST>
Use single line : 20.40.250.30 - - [02/Sep/2025:05:04:35 +0000] "GET...
Results
=======
Failregex: 1 total
|- #) [# of hits] regular expression
| 1) [1] <HOST>
`-
Ignoreregex: 0 total
Date template hits:
|- [# of hits] date format
| [1] Day(?P<_sep>[-/])MON(?P=_sep)ExYear[ :]?24hour:Minute:Second(?:\.Microseconds)?(?: Zone offset)?
`-
Lines: 1 lines, 0 ignored, 1 matched, 0 missed
[processed in 0.02 sec]What's going on here? I use single quotes around my line to put it as the first argument. Then, as the second argument, I'm giving it "<HOST>". This asks fail2ban-regex to try to parse it, and since it's a standard access_log, it managed to find the HOST (20.40.250.30), and it also found a matching date template. Not very surprising. If I break the log line by making the IP and/or the date invalid, fail2ban-regex no longer prints the two [1] hits for the input:
fail2ban-regex '2fdf0.40.250.30 - - [fd02/Sep/2025:05:04:35 +0000] "GET /developmentserver/metadatauploader HTTP/1.1" 404 245 "-" "Mozilla/5.0 zgrab/0.x"' "<HOST>"
Running tests
=============
Use failregex line : <HOST>
Use single line : 2fdf0.40.250.30 - - [fd02/Sep/2025:05:04:35 +0000]...
Results
=======
Failregex: 0 total
Ignoreregex: 0 total
Date template hits:
Lines: 1 lines, 0 ignored, 0 matched, 1 missed
[processed in 0.02 sec]
|- Missed line(s):
| 2fdf0.40.250.30 - - [fd02/Sep/2025:05:04:35 +0000] "GET /developmentserver/metadatauploader HTTP/1.1" 404 245 "-" "Mozilla/5.0 zgrab/0.x"
`-OK, so now we have a log file with lines fail2ban is ready to eat. Let's create a filter in /etc/fail2ban/filter.d/nginx-block-path.conf:
[Definition]
failregex = ^<HOST>
ignoreregex =
Pretty simple. The added "^" is just telling fail2ban the IP will be first — not needed, but also not hurting. There's not much to say about that, so on to the jail definition. Creating another file /etc/fail2ban/jail.d/nginx-block-path.conf:
[nginx-block-path]
enabled = true
filter = nginx-block-path
logpath = /var/log/nginx/blocklist.log
maxretry = 1
bantime = 3600
The jail is the action to be taken by fail2ban. There's a man file explaining it, and looking at my example here, it means:
- enabled — …
- filter — tells it to use our filter
- logpath — the nginx logfile to read from
- maxretry — max tries to make fail2ban block, directly in our case since we have defined the client as evil already
- bantime — 3600 seconds, aka 1h
There are also a lot of defaults set in jail.conf, and you can override them in jail.local (or a separate file). The only thing left now is to (re)start fail2ban, and it's all set:
systemctl restart fail2ban
Recap
So, now when someone tries to access /developmentserver/metadatauploader, Nginx returns 403 and writes a line in /var/log/nginx/blocklist.log. Fail2ban picks up the line and applies a one-hour ban. You can see the bans in fail2ban with:
fail2ban-client status nginx-block-path
Status for the jail: nginx-block-path
|- Filter
| |- Currently failed: 0
| |- Total failed: 122
| `- File list: /var/log/nginx/blocklist.log
`- Actions
|- Currently banned: 1
|- Total banned: 86
`- Banned IP list: 141.95.66.115
And in iptables with:
iptables -L -n
Chain f2b-nginx-block-path (1 references)
target prot opt source destination
REJECT 0 -- 141.95.66.115 0.0.0.0/0 reject-with icmp-port-unreachable
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
Improvements and next steps
This blocks one type of attack. It's very reactive, as you can continue adding to the regexp in your /etc/nginx/snippets/blocklist.conf, but you'll never preemptively block anything.
There are projects you can help (and use), such as https://www.blocklist.de and https://www.projecthoneypot.org. The nice thing with them is that they'll help others as well, and you can be a part of the (nice side) of the bots.
If you're looking for a commercial solution, Cloudflare is a pretty obvious one.