The ban pipeline
The scan_nginx scoring engine decides who should be
banned and writes two log files. This page documents what turns those files into
an actual 444 on the next request. Three separately-scheduled BOA monitor
scripts carry an offender from a log line, through the firewall, and back into the
Nginx $is_banned geo:
scan_nginx.sh
│ writes offenders
▼
web.log + scan_nginx.archive.log
│ │
▼ ▼
guest-fire.sh guest-water.sh
web.log → csf -td archive ≥12 → csf.deny
900 (80/443) "Brute force Web Server"
(temporary, 15 min) (persistent; ≥24 = do-not-delete)
│ │
└──────────┬───────────┘
▼
/etc/csf (csf.tempban 80/443 + csf.deny)
│
▼
nginx_deny.sh — regenerate the geo from current csf state
│
▼
/data/conf/nginx_banned_ips.conf
│
▼
geo $remote_addr $is_banned (Nginx)
│
▼
return 444 (silent drop, zero backend cost)
The defining property of this stage is the same split that defines the whole
Abuse Guard: detection is post-hoc, enforcement is real-time. scan_nginx
analyses log lines that already happened; the pipeline turns its verdict into a
CSF ban, and the Nginx geo — a derived mirror of CSF, fully regenerated every
pass — makes that ban bite on every subsequent request and self-expire the
moment CSF drops it.
Stage 1 — temporary ban (guest-fire.sh)
guest-fire.sh is the fast path: a 15-minute temporary CSF ban for every fresh
offender scan_nginx wrote to web.log. For the web tier it reads
/var/xdrago/monitor/log/web.log, takes the IP field
(cut -d '#' -f1 | sort | uniq), and for each unique IP:
- Looks the IP up in CSF (
csf -g) and checkscsf.allowfor an explicittcp|in|d=80|s=<ip>allow entry. - If allowed (in
csf.allow, or showing anALLOW … ACCEPT … dpt:80rule), it is cleared —csf -drandcsf -trremove any stray block — and never banned. The allow list always wins. - If already denied on 80 or 443, nothing is done.
-
Otherwise it issues a 15-minute temporary ban on both web ports:
csf -td ${_IP} 900 -p 80 csf -td ${_IP} 900 -p 443
The same routine runs over three log files with identical allow-first /
already-denied / temp-ban logic: the web block above (web.log, ports 80/443)
plus an SSH twin (ssh.log, port 22) and an FTP twin (ftp.log, port 21).
Looping and the water interlock
The guard runs five times per invocation with a 10 s pause between passes, so an IP that appears mid-run is still caught within the same tick:
for _iteration in {1..5}; do
[ ! -e "/run/water.pid" ] && _guest_guard
sleep 10
done
The [ ! -e "/run/water.pid" ] test is the interlock with Stage 2: while
guest-water.sh runs (it touches /run/water.pid on start and removes it on
exit), guest-fire.sh skips its guard passes, so the two never fight over CSF
at the same time.
Self-healing watchdog
Under a real flood web.log can hold thousands of IPs and a single run can
stretch from its normal ~50 s (5 × 10 s) into minutes, blocking the following cron
slots. A watchdog records the PID in /run/fire.pid, and on the next invocation
kills any predecessor still alive past _FIRE_TIMEOUT (180 s), logging to
/var/log/boa/fire_stuck.log. A normal overlap (within the timeout) is left to
the single-instance lock, which exits early if more than two copies run. The full
watchdog stack is on Operations + tuning.
Stage 2 — persistent escalation (guest-water.sh)
guest-fire.sh only ever applies temporary bans. Repeat offenders are escalated
to a persistent CSF deny by guest-water.sh, which runs on its own (slower)
schedule and works from the cumulative archive
/var/xdrago/monitor/log/scan_nginx.archive.log (_WA) rather than the per-tick
web.log. For each unique IP it counts how many times that IP appears across the
whole archive:
_NR_TEST=$(tr -s ' ' '\n' < ${_WA} | grep -cF "${_IP}")
That count drives a two-tier escalation (after the csf.allow /
/root/.local.IP.list exemptions):
| Archive hits | Action | CSF comment |
|---|---|---|
≥ 12 |
csf -d persistent deny |
Brute force Web Server N attacks |
≥ 24 |
csf -d persistent deny, do-not-delete |
do not delete Brute force Web Server N attacks |
The 12-hit deny lasts until the next CSF limits rotation (routine csf.deny
trimming reclaims it). The 24-hit deny carries the literal do not delete tag,
which CSF's routine trimming respects — so a heavy repeat offender stays banned
across the housekeeping that would otherwise expire it. The one thing that does
clear it is the operator-triggered full cleanup: when
/etc/boa/.full.csf.cleanup.cnf exists, guest-water.sh strips every
do not delete line from csf.deny (sed -i "s/.*do not delete.*//g") — the
explicit "wipe even the permanent bans" override.
guest-water.sh runs the same escalation over three archives in parallel
structure:
| Tier | Archive var | Archive file | CSF label |
|---|---|---|---|
| Web | _WA |
scan_nginx.archive.log |
Brute force Web Server |
| SSH | _HA |
hackcheck.archive.log |
Brute force SSH Server |
| FTP | _FA |
hackftp.archive.log |
Brute force FTP Server |
For the whole run guest-water.sh holds the Stage 1 interlock — it touches
/run/water.pid early (after its initial /root/.local.IP.list unblock pass, not
first) and rms it at the very end — and once escalation is done it clears the
per-tick web.log / ssh.log / ftp.log so the next scan_nginx window starts
clean.
Under the hood.
guest-water.shalso refreshes thecsf.allowprovider ranges (Cloudflare, Googlebot, Bingbot, Pingdom, and — behind/root/.extended.firewall.exceptions.cnf— Imperva, Sucuri, Auth0, Site24x7), with a diff-guard that reverts an unexpectedcsf.allowchange and per-provider backups under/var/backups/csf/water/. That allow-list maintenance is what makes the keystone_is_whitelisted_ipguard reliable across the whole pipeline.
Stage 3 — geo regeneration (nginx_deny.sh)
A CSF deny stops traffic at the origin firewall — but on a Cloudflare-proxied
vhost the origin only ever sees the Cloudflare edge IP, so an iptables/CSF ban on
the real client never bites. nginx_deny.sh closes that gap: it mirrors the web
bans into an Nginx realip-keyed geo deny set, so the banned real client is dropped
with a 444 at Nginx even when it arrives via Cloudflare.
It rebuilds /data/conf/nginx_banned_ips.conf from current CSF state on every
run, collecting web bans only from two sources:
# Active web temp bans: csf.tempban rows whose port field is 80 or 443
awk -F'|' '($3 == "80" || $3 == "443") { print $2 }' /var/lib/csf/csf.tempban
# Persistent web offenders: csf.deny lines tagged by guest-water
grep -F "Brute force Web Server" /etc/csf/csf.deny | awk '{ print $1 }'
This is deliberately web-scoped: it picks up the 80/443 temp bans from
Stage 1 and the Brute force Web Server denies from Stage 2, and it excludes
the SSH/FTP csf.deny entries and the broad CIDR blocklists — those work at the
network layer (they are not Cloudflare-proxied) and have no place in the Nginx
geo.
Each collected token is then validated as a bare IPv4 address or an IPv4 CIDR —
every octet 0-255, the prefix 0-32:
_ipv4_octet="(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])"
[[ "$1" =~ ^(${_ipv4_octet}\.){3}${_ipv4_octet}(/(3[0-2]|[12]?[0-9]))?$ ]]
A loose pattern would let a malformed token (999.1.1.1, /99, IPv6, junk) reach
the geo map and fail the Nginx configtest for the whole box. Only IPv4/CIDR
tokens become IP 1; lines here — nginx_deny.sh stays IPv4-only because its
source (csf) is IPv4-only; IPv6 offenders travel a separate path.
Stage 3b — IPv6 geo regeneration (nginx_deny6.sh)
csf cannot hold an IPv6 ban, so an IPv6 offender never reaches nginx_deny.sh.
Instead scan_nginx's _block_ip writes it to an nginx-native store
(/var/xdrago/monitor/log/web6.tempban, lines <ip6>|<expiry-epoch>), and
nginx_deny6.sh (*/2, same shared lock) prunes the expired entries, collapses the
refreshed duplicates to the max expiry, validates each as a strict IPv6 address, and
emits the survivors as <ip6> 1; into /data/conf/nginx_banned_ips.conf6. That file
is pulled by the same geo $remote_addr $is_banned set (its wildcard
nginx_banned_ips.c* include already covers it), so an IPv6 attacker gets the same
444. The store self-expires (default 900s, _NGINX_V6_BAN_TTL) — the nginx
equivalent of csf's 15-minute web temp ban — and a re-offence refreshes it. IPv6 can
only arrive via the trusted realip proxy, so a banned v6 is always the real client.
The whole IPv6 arm is gated by _NGINX_V6_BAN_DETECT (default YES).
Safe, idempotent reload
The rebuild only disturbs Nginx when something actually changed:
- Change-gate. The freshly-built file is compared to the live one with
cmp -s; if identical, the run exits with no reload. - Atomic install + validate. The new file is written to a leading-dot temp in
the same directory (so the
.c*include glob never sees it) andmv-d into place; the current file is backed up to.nginx_banned_ips.last_good.conffirst. - Revert on failure. After install it runs
service nginx configtestandservice nginx reload; on any failure it restores the last-good file (and reloads), so a bad ban set can never take Nginx down. - Shared lock. The whole run holds the advisory lock
/run/boa_nginx_config.lock(flock -w 30), shared with the other BOA Nginx-config writers (ip_access,user_admin_access,cloudflare_realip,ai_policy,nginx_deny6,migration_proxy_realip) so their configtest+reload cycles never overlap. If the lock can't be taken within 30 s the run skips and retries next tick.
The Provision templates own the consuming side — the geo $remote_addr $is_banned map, the if ($is_banned) return 444; guard, and the wildcard include
of the generated file — documented in request guards.
Why the geo self-expires
The geo file holds no state of its own. Each nginx_deny.sh run rebuilds it
from scratch out of the current CSF tables, so the lifecycle is entirely
CSF's:
- A Stage 1 temp ban lives in
csf.tempbanfor 15 minutes. While it is there it appears in the geo; once CSF drops the expired row, the nextnginx_deny.shpass simply doesn't emit it and the geo reload removes the444. - A Stage 2 persistent deny stays in the geo until CSF removes the
csf.denyline — at limits rotation for a≥12ban, or never (until manual action) for ado not delete≥24ban.
So bans self-expire without any manual cleanup: the operator manages CSF, and
the Nginx geo follows automatically. To lift a ban early, clear it in CSF
(csf -dr / csf -tr) and the next geo regeneration drops it — see
Operations + tuning.
Release valve: clearwebbans
Everything this page documents can be unwound in one command: clearwebbans
removes the Stage 2 csf.deny Brute force Web Server escalations (csf -dr),
then the temporary 80/443 bans (csf -tr), clears the per-tick web.log plus
both archive generations (scan_nginx.archive.log and the guest-water-rotated
scan_nginx.archive.x3.log — so guest-fire.sh cannot re-apply and
guest-water.sh cannot re-escalate from history), resets the
/var/log/scan_nginx_lastpos byte offset to the current end of access.log,
and finally regenerates the geo via /var/xdrago/nginx_deny.sh. It is strictly
WEB-scoped: SSH/FTP bans and their archives are untouched. The run is
idempotent, supports --dry-run, and holds a lock at /run/clearwebbans.lock.
The full command reference — flags, output, when to run it — lives on
Operations + tuning.
Allow list wins at every stage
None of the three stages trusts the previous stage's output blindly — each one re-checks the CSF allow list before it acts:
guest-fire.shclears (never bans) any IP incsf.allow/ showing anALLOWrule.guest-water.shskips any IP listed in/root/.local.IP.list, never re-denies an IP already shown denied or allowed in the livecsf -goutput, and unblocks (csf -dr/csf -tr) only the IPs it finds incsf.allow.nginx_deny.shonly ever emits IPs that are currently banned in CSF, so an allow-listed IP that was never banned never reaches the geo.
This is the same keystone guarantee scan_nginx enforces with
_is_whitelisted_ip: to exempt a service fleet-wide, put its IPs on the CSF allow
list and every stage of the pipeline honours it. See
scan_nginx scoring engine — Whitelisting
and Operations + tuning.
Related
- scan_nginx scoring engine — the detection half that
feeds
web.logandscan_nginx.archive.log. - Request guards — the
$is_bannedgeo and the real-time444this pipeline ultimately drives. - Configuration reference — the
_NGINX_DOS_*knobs and the.instant.csf.block/.full.csf.cleanupmarkers. - Operations + tuning — read live ban state, lift or cycle a ban, whitelist an IP.
- Security & isolation — CSF + LFD firewall — the firewall lifecycle that consumes this pipeline's output.