There is a dense hotspot over one country on the attack map, and a rule saying "block the whole country" suggests itself. It will achieve almost nothing: changing the exit country costs an attacker a few minutes, while the ban catches your own visitors and you yourself while travelling. Worse, a visible crawler coming from a single cloud will move onto residential addresses after the block and become indistinguishable from people.

Below are five steps that do produce a result on the same server: cut off the scanners, cap the pace, avoid cutting off what you need, and — if it really is necessary — block properly. All of it can be checked and set up in one sitting.

Step 1. See who is actually making the hotspot

First find the request log. On a server with several sites access.log is often empty, while the entries go into the virtual host's own log:

sudo ls -lS /var/log/apache2/*access*.log /var/log/apache2/*requests*.log 2>/dev/null

In every command below, substitute the file that came out on top of that list. First the leading addresses for the day, then what the one at the top is actually asking for:

sudo awk '{print $1}' /var/log/apache2/access.log | sort | uniq -c | sort -rn | head -20
sudo grep '^203\.0\.113\.' /var/log/apache2/access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -20

The second command answers the main question. If the output holds dozens of different pages of your own site, this is a content scraper: harmless, and it is enough not to count it as a visitor. If it holds /wp-login.php, /.env, /phpmyadmin and other things you do not have, this is a scanner, and step 2 deals with it. For nginx the logs live in /var/log/nginx/; the fields are the same.

Step 2. A jail for requests to things you do not have

The requested path cannot be faked — unlike the address and the User-Agent, it is the intent. The filter, /etc/fail2ban/filter.d/web-probes.local:

[Definition]
failregex = ^<HOST> .* "(GET|POST|HEAD) /(wp-login\.php|wp-admin|xmlrpc\.php|\.env|\.git/|phpmyadmin|administrator/)
ignoreregex =

The jail, /etc/fail2ban/jail.d/web-probes.local:

[web-probes]
enabled  = true
port     = http,https
filter   = web-probes
logpath  = /var/log/apache2/*access.log
maxretry = 1
findtime = 1h
bantime  = 1d

maxretry = 1 is justified here: nobody reaches somebody else's admin panel by accident. Apply it and confirm that the filter matches lines in your own log:

sudo fail2ban-regex /var/log/apache2/access.log /etc/fail2ban/filter.d/web-probes.local
sudo systemctl reload fail2ban && sudo fail2ban-client status web-probes

Step 3. Cap the pace

A scanner and a crawler give themselves away by speed, whatever country they come from. Thirty pages a minute from one address is a threshold that does not catch a person browsing a catalogue:

[http-flood]
enabled  = true
port     = http,https
filter   = apache-overflows
logpath  = /var/log/apache2/site_access.log
maxretry = 30
findtime = 60
bantime  = 1h

The critical detail is which log is named in logpath. If the pattern also covers the log of your own control panel or admin area, you will ban yourself: a single page there pulls a dozen requests for styles and scripts, and thirty add up in a couple of clicks. So give the panel its own log file that the jail's pattern does not match, and give static files a long cache lifetime so the browser does not re-request them on every page. The symptom if it happens anyway: the HTML arrives, the styling does not, the page hangs. To lift the ban:

sudo fail2ban-client set http-flood unbanip 203.0.113.10

Step 4. Protect what must not be cut off

Before switching blocks on, write down the exceptions — otherwise the first thing they catch is something you needed:

[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24 66.249.64.0/19

What has to be on that line, and why:

  • Your own address or your office subnet. The most common cause of losing access to a server is your own rule, not an attack;
  • The 66.249.64.0/19 range. Google's crawler identifies itself by User-Agent, but the part of it that executes JavaScript arrives as an ordinary Chrome/Linux and never names itself. Any rule along the lines of "block data-centre traffic" hits precisely that, after which pages quietly drop out of the index. A suspicious address can be checked with a reverse lookup: dig -x 66.249.66.1 +short should return a name in the googlebot.com zone;
  • The addresses your payment notifications come from. Payment webhooks arrive from AWS and Azure servers. Close those clouds wholesale and payment confirmations stop reaching you — and you will find out when a customer writes that the money is gone and the access is not there. The ranges come from your payment provider's documentation.

Step 5. If a block really is needed, block a subnet, not a country

A steady thickening from one network that holds for days and comes with uniform requests is a single operator working down a list. That case is blocked narrowly and temporarily:

sudo ufw deny from 198.51.100.0/22 to any
sudo ufw status numbered

Use the second command to note the rule number: lifting the block later goes through it, sudo ufw delete <number>. Agree with yourself on a term — a month, say — and put the removal in the calendar. Rules added "for good" turn, a year later, into a list nobody dares touch, because nobody remembers what those addresses did to get there.

To find out which subnet to block, start from the address from step 1: whois 198.51.100.17 | grep -iE 'route|cidr|netname'.

What changes after these five steps

Scanners are cut off by intent rather than by origin, and are caught on the first request. Crawlers run into the rate limit. The search crawler and the payment notifications keep coming through. The only block left that is wide at all is a narrow one, with a known expiry and a clear reason.

The map does not go anywhere, but its role changes: it is there not to close countries off but to notice the thickening in the first place and compare it with last week. That takes a history rather than a snapshot of the current day. What such a map looks like alongside the ban log and the list of open ports is on the demo pages below.