A TUI, a web UI and a CLI that help you configure your server to stop bad bots without hiding behind a CDN.
It works alongside NGINX and your existing firewall (iptables or nftables):
- NGINX config. - It classifies known bots by category (scanners, search engines, AI crawlers) and blocks or allows them by injecting a rule into your site configs. It scans the NGINX log to detect bots dynamically and block them even if no ruleset tracks them yet.
- A firewall script. - Block entire countries, datacenter IP ranges, known bot IP ranges or any IP address that repeatedly tries to log into your server unsuccessfully.
The app tries its best to not lock you out of the server, but you use it on your own risk. And note that it is licensed under AGPL, so if you are using it commercially, make sure you obey the license.
From crates.io:
cargo install stop-bots
Or build from a checkout:
cargo install --path .
A prebuilt x86_64 Linux binary is available on GitHub releases.
Requires Rust 1.88 or newer to build. Linux only in practice: it shells out to
systemctl, nginx -t and nft/iptables, so while it compiles elsewhere it won't be
much use there.
-
Known bots, by category (scanner / search engine / AI crawler), sourced from ArcJet's Well-Known Bots, ai.robots.txt and the NGINX Ultimate Bad Bot Blocker list. Blocking a category injects an
if ($http_user_agent ...)rule into each site's NGINX config. -
Too many requests, via NGINX's own rate limiting.
-
Politely, first — a generated
robots.txtlisting every bot you're blocking, for the crawlers that honour it, plus the honeypot path below. -
Except where you say otherwise — per-site path exemptions.
-
Requests that don't look like a browser, per site. Six independent rules, each its own toggle and each off by default — one switch per rule so that if something of yours stops working, you can tell which rule did it:
Rule Turns away, besides bots HTTP/1.0 and HTTP/1.1 crawlers and API clients that don't speak HTTP/2 No Acceptheadersome API clients send none No Accept-Languageprivacy tooling strips it Empty/absent User-Agentscripts and health checks often omit it Hostis a bare IPbreaks reaching the site by IP TLS 1.0 / 1.1 very old clients only
There are seven choices:
| Option | What it's for |
|---|---|
403 Forbidden (default) |
says the block was deliberate; the only one a wrongly-caught human can act on |
404 Not Found |
hides that anything was blocked at all |
410 Gone |
asks well-behaved crawlers to drop the URL for good — prefer this over 403 when you're turning away crawlers rather than attackers |
429 Too Many Requests |
tells a polite client to back off and retry |
418 I'm a teapot |
RFC 2324's joke. It works; it just isn't IANA-registered, and NGINX sends it with an empty body |
444 close connection |
no reply at all; cheapest, but indistinguishable from the server being down |
Tarpit |
answers 403 but trickles the body at one byte per second, so the client waits instead of moving on |
The tarpit is the gentlest option for a false positive — a wrongly caught client is slowed,
not refused — and the harshest on cost for a bot, whose connection sits idle. One thing to
know before choosing it: it holds one of your worker connections for the duration too, so
a flood of tarpitted clients competes with real visitors for worker_connections.
Each of these is an independent switch on the Dashboard's "Automatic blocking" panel, and each adds a temporary firewall block that expires on its own and is re-added if the behaviour continues.
They run on an internal timer that re-reads your SSH and NGINX access logs every minute — but only while the TUI or the web UI is running. Either one keeps the same schedule, in the same database, so leaving the web UI up is enough; nothing is detected when neither is running. For a server with no stop-bots process on it at all, see Unattended, from cron below.
- SSH and web scanners: IPs with a pile of failed SSH logins, or many distinct 404'd paths. Never an IP with a recent successful SSH login, or one inside a known crawler's published IP range.
- Forged crawlers: anything claiming to be Googlebot, Bingbot or GPTBot from an address that crawler's own operator doesn't publish. The cheapest common disguise there is. Inert until those lists have actually been fetched.
- Probing for exposed secrets: a single request for
/.env,/.git/config,/wp-config.phpand similar is an immediate ban. The built-in list deliberately leaves out paths that are legitimate somewhere —/wp-login.php,/wp-admin/,/xmlrpc.php,/phpmyadmin— since locking out your own administrator would be worse than missing a scanner the 404 detector catches anyway. Add your own withset-probe-paths. - Honeypot: a path published only as
Disallow:in the generatedrobots.txtand linked nowhere. Reaching it means ignoring robots.txt, which deserves a ban. Needs robots.txt generation turned on to work at all.
Three more look at how a client behaves rather than what it asks for. All three are off by default, because each has false positives — and all three exempt verified search-engine crawlers, which would otherwise match every one of them:
- Fetches no assets: many distinct pages and not one stylesheet, script or image. Browsers
load what goes with a page. Won't catch an API client (it counts distinct paths, and an
API client hits few) or a well-cached returning visitor (a
304counts as a fetched asset). Can't help you on a site that serves no assets at all. - Rotating user agent: several identities from one address. May block a carrier, campus or office gateway that presents many real browsers on one IP.
- Crawls with no referer: many distinct deep pages, never a
Referer. Weakened byReferrer-Policy: no-referrerand privacy tooling; the distinct-path threshold makes it usable.
- Whole countries, via IPdeny's aggregated CIDR lists — block specific countries, or flip to allow-list mode and block everything else.
- Known-bad addresses, via third-party lists: FireHOL level 1, Tor exit nodes and blocklist.de. All off by default.
- Whole hosting providers: AWS, Google Cloud and DigitalOcean publish their address space, and residential visitors don't browse from it. They block every visitor hosted there, including VPN endpoints, corporate egress and API clients, not just bots. Off by default.
- Neighbouring addresses, optionally: when several addresses in one IPv4
/24are flagged in the same pass, block the/24. Off by default — blocking 256 addresses because three misbehaved is collateral by design. (IPv6 is different and needs no switch: a detection always blocks the/64, because a/64is one LAN, the same thing a single IPv4 address represents. Blocking the single address an IPv6 attacker happened to use would stop nothing — they have 2^64 more.) - Anything else, by hand — add an IP/CIDR allow or block rule directly, or use the Firewall screen to permanently block a specific IP or user agent you've spotted before it ever crosses an automatic threshold.
Every decision is generated, never applied automatically.
Three things can apply the firewall script for you, and all three need you to ask: the TUI's
render popup ("apply after writing") or its a key, the web console's firewall panel ("run it
after writing") or its "Apply everything" button, and batch --apply from a crontab you wrote
— see Unattended, from cron.
The same applies on the NGINX side: changing a setting only changes what would be written.
The NGINX screen shows each site as STALE until you apply.
Switching a detector off never removes blocks it already added — those expire on their own.
"Stop detecting" and "undo what was detected" are deliberately separate; the second is the
Firewall screen or remove-firewall-rule.
Run the binary with no arguments to launch the TUI, stop-bots web for the same screens in
a browser (see The web UI), or see stop-bots --help for the full list of
CLI subcommands. All UIs can be used together. Configure everything in the TUI or web UI and
then use the CLI in a crontab to keep the rules updated.
1–4 jump to a screen, ? opens a full key-binding reference at any time, and :
opens a command palette listing every action by name. You can exit the app, or back out of
a popup or submenu, with q or Escape. The digits and ? work in the web UI too; : is
the TUI's own.
You can switch between the dark and light theme with 't'. The app will try to auto-detect the theme, but for some terminal and multiplexer combinations there isn't enough information available to make the correct choice.
The Dashboard and the Firewall screen own everything that ends up in the firewall script; the NGINX screen owns everything that ends up in NGINX config.
- Dashboard (the default screen): system-wide category defaults (Scanners / Search Bots /
AI Bots — Allowed or Blocked); host-wide geo-blocking (block or allow-list specific
countries); an "Automatic blocking" panel with an on/off switch for each detector and each
third-party blocklist; a "Firewall script" panel (how many rules a render would write,
whether the script on disk is stale, and the sites and bot lists it is rendered from); a
"Scheduled" panel showing the internal cron's jobs and when they last ran (with a spinner
next to any job currently running in the background); and a Log of what the app last
did. A status strip under the tabs shows the host's health checks on every screen.
Up/Downflow between the three lists;mswitches geo mode. PressFto render the current firewall rules to a script — the popup also has an "apply after writing" toggle (Space) for actually enforcing it immediately, instead of applying it by hand afterward. Three more keys act on the whole host:udownloads every list,aapplies both planes (NGINX, then the firewall), andwputs this console behind NGINX — the same three the browser has as buttons and a panel. - Bot settings: lists every known bot-list source (with an action to refresh it) and every individual bot, searchable by name, with a per-bot override (Allowed / Blocked / follow the category default).
- Firewall: a live, actionable view of what's currently hitting the server — "Top
IPs attempting SSH connection" and "Top User Agents", each ranked by count and tagged
NOT BLOCKED/BLOCKED(shown in red).Tab/Shift+Tabswitch which of the two panelsUp/Downapply to;fcycles a shared filter (all / not blocked only / blocked only);Enterblocks the selectedNOT BLOCKEDrow, or unblocks it if it's alreadyBLOCKED.iinspects the selected address: which of the reputation feeds list it, whether it is inside a published crawler range (which is what separates a real Googlebot from a user agent that merely says so), which country it belongs to, and which accounts it tried to log in as. All of it from lists this host has already downloaded — there is no reverse DNS or whois lookup here, because a PTR record is written by whoever holds the address and would be attacker-supplied text that reads as authoritative. - NGINX: an "NGINX settings" panel (
Tabto focus it) holding the host-wide choices that shape generated config — what a blocked request gets back (see above), whether to serve a generatedrobots.txt, and rate limiting — above every NGINX site discovered on disk, each with a live "up to date / stale / not found" status and actions to apply the current policy to one site or all of them. Changing any of those settings flips every applied site toSTALE, which is your cue to re-apply. Opening a site lets you override its category/bot policy, switch on any of the six request-shape rules, and list paths exempt from blocking. - Help: the full key-binding reference.
The Dashboard, where the host-wide policy and the firewall script live:
Bot settings, where every list source and every individual bot lives:
Firewall, the live view of what is hitting the server right now:
NGINX, where the host-wide choices sit above every site found on disk:
stop-bots batch is one pass over everything the TUI does by hand: refresh every list,
scan the logs, write the NGINX blocking rules and the firewall script.
# One full pass a night. Refreshes the lists, scans the logs, applies both.
0 4 * * * root /usr/local/bin/stop-bots batch --apply --ssh-log /var/log/auth.log
# And detection every ten minutes, without re-downloading lists that change weekly.
*/10 * * * * root /usr/local/bin/stop-bots batch --apply --no-fetch --ssh-log /var/log/auth.log
It says nothing when everything worked, so a healthy nightly run doesn't mail you. A failed
step prints to stderr and sets a non-zero exit status, which is what makes cron tell you
about it. Run it once by hand with --verbose first — that prints a line per step, and is
the easiest way to see what it is actually doing.
--apply is what makes it enforce anything. Without it, batch writes the NGINX config
and the firewall script and stops: config does nothing until a reload, a script does nothing
until it is run.
batch and a long-running front-end coexist safely. The TUI, the web UI and batch all
record what they did through the same keys in the same database, so whichever gets to a job
first does it and the others find it no longer due.
With --apply, the SSH lockout guard can refuse — and refusing means nothing is applied.
It refuses if the rules would block a client that is connected right now, and if no SSH log
could be read at all, because then the check could not run. --force overrides
the guard if you mean it.
One step failing never stops the others, and the NGINX and firewall halves are independent — a failed NGINX reload still leaves the firewall applied, and the other way round.
stop-bots web serves the same five screens in a browser.
stop-bots web
It binds 127.0.0.1:8787 — reachable only from that machine — and prints a generated password once, on first run. Reach it from your laptop over an SSH tunnel:
ssh -L 8787:127.0.0.1:8787 your-server
then open http://127.0.0.1:8787/.
The console follows the operating system's light or dark setting, with a toggle in the
header; the TUI screenshots above are the dark theme, these the light one. The keys the
TUI uses work here too: 1–4 switch screens, / focuses the search box, ? opens Help.
Three host-wide actions live in the header, in the browser as buttons and in the TUI as single keys:
- Update everything (
u) downloads every bot list, every crawler IP range, every enabled reputation feed and every selected country — the same setstop-bots batchfetches, from the same plan. One source failing does not stop the rest, and nothing is enforced until something applies it. - Apply everything (
a) writes and reloads the NGINX config, then writes and runs the firewall script. The two planes are independent: whichever fails, the other still gets its turn, because a half-applied host beats one where an NGINX syntax error also left the firewall stale. - Web Access (
w) sets NGINX up to serve the console itself — see Behind NGINX.
sudo stop-bots install web
Writes /etc/systemd/system/stop-bots-web.service, creates /var/lib/stop-bots (0700 — it
holds the console's password hash) and /etc/stop-bots, generates a password if there isn't
one, and enables and starts the unit.
--dry-run prints the whole plan and changes nothing. --prefix <dir> writes the same tree
somewhere you can read it without root. If the unit already exists and you have edited it, the
installer stops and says so rather than replacing your edit; --force if you meant it.
The service runs as root, because the console rewrites /etc/nginx, writes the firewall
script, and runs nginx -t and systemctl reload nginx.
Only Debian is checked for, because that is what has been tested; the unit is very likely correct on any systemd distribution, but the SSH log path it assumes is Debian's.
Binding anything but loopback takes a second, deliberate flag, because this console can rewrite the firewall and the NGINX config of the host it runs on:
stop-bots web --bind 0.0.0.0:8787 --expose --allowed-hosts admin.example.com --save
--allowed-hosts is not optional in practice: a request carrying a host name that isn't
listed is refused.
Put it behind NGINX with TLS — the same NGINX this tool is protecting. If you do, and the
proxy sets X-Forwarded-For, tell the console it may believe that header, or it cannot
tell which address a request really came from:
stop-bots web --bind 127.0.0.1:8787 --trust-forwarded-for true --secure-cookie true
--secure-cookie true is for TLS. Without it a browser will send the session cookie to an
http:// URL for the same host as well. stop-bots install web takes both flags too, and
for a service that is the place to set them. Both stay set until you pass false.
--trust-forwarded-for matters more than it looks. Without it every request behind a
proxy arrives from 127.0.0.1, so the console cannot tell one client from another — which
means a flood of login attempts shares the same throttle bucket as you, and the guard that
stops you blocking your own address has nothing to compare against. With it, both work
per-client. The console believes only the last address in the header, the one the proxy
itself added.
The console can set this up for you, and so can the TUI (w on the Dashboard). Both
write the NGINX config, record the path prefix and add the host name to the allowlist — the
three things that have to agree, because a missing prefix makes every link leave the
location block and a missing host name makes every request a 403. Both validate with
nginx -t before the config can take effect, roll it back if that fails, and record the
new address only once it validated.
Two modes, and path is the default for a reason: it adds a location block to a site you
already have, so the console inherits that site's certificate. A subdomain needs its own,
and until certbot --nginx -d <host> has run, this console's password form and session
cookie cross the network in the clear.
Path mode shares an origin with everything else on that site. A script running on any other page of it can use your logged-in console. Use path mode only on a site that runs nothing you don't fully trust; otherwise, use a subdomain.
The rest of this section is the same thing by hand, which is worth reading once even if you use the panel — the trailing-slash trap below is the mistake it exists to prevent.
A subdomain is the simpler deployment, and the one to take if you can:
server {
server_name stopbots.example.com;
location / {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}stop-bots web --allowed-hosts stopbots.example.com --save
A path prefix works too, but the console has to be told about it — it needs to generate every link, form action, redirect and cookie path with the prefix already in them, and it cannot guess:
stop-bots web --base-path /stop-bots --allowed-hosts example.com --save
location /stop-bots/ {
proxy_pass http://127.0.0.1:8787; # NO trailing slash
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}The trailing slash on proxy_pass matters, and its absence is the whole trick.
Without it, NGINX passes the full path through and stop-bots sees
/stop-bots/whatever, which is what it now serves and generates. With a trailing
slash, NGINX strips the prefix — and then the browser resolves the links in the page
against the domain root, lands outside the location block, and everything 404s. No
amount of care on the server's side can fix that, so the prefix has to survive the
proxy.
Nothing enforces this from outside, but the failure is loud rather than subtle: with the prefix configured, an unprefixed request is a plain 404 rather than a page that half-works.
Two things are missing on purpose, and the Help screen says so with the reasons:
- It will not unblock something a downloaded list blocked — the next refresh of that list would silently undo it.
- It will not change its own password. Use
stop-bots web --set-passwordon the host.
It also refuses to block the address you are connected from, which would take away the console you'd use to undo it.
Login attempts are throttled. Not because the password is guessable — it is generated, 144 bits — but because verifying one runs Argon2id, and letting an unauthenticated caller drive that as fast as they can post is a denial of service against the host this tool is supposed to be protecting. Ten wrong attempts are free; past that a client backs off exponentially.
If NGINX is in Docker and its config is on a bind mount, systemctl reload nginx reloads
nothing. Point the two commands at the container instead — this applies to the CLI and the
TUI as well:
stop-bots set-nginx-commands \
--test "docker exec web nginx -t" \
--reload "docker exec web nginx -s reload"
The command is split into words and run directly. It never goes through a shell, so ;,
| and $VAR are ordinary characters rather than syntax.
Contributions are not accepted at this time.
You can contact me at marko@ivankovic.me.
Copyright (C) 2026 Marko Ivankovic
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
See the LICENSE file for the full text of the License.
Alternative licensing is NOT available.


