Abuse Guard code internals
This is the maintainer view of scan_nginx.sh — the ~1975-line post-hoc log
analyser that is the detection half of the Abuse Guard. It is for people
extending it: adding a detector, retuning a default, or tracing why a helper
that passed offline banned nothing in production. It documents the mechanisms,
not the operator knobs; the effective thresholds and the reader-facing tuning
model live on the Operating side, and every _NGINX_* default is in the
variables reference.
Everything here is verified against scan_nginx.sh and its launcher
nginx_guard.sh at HEAD, plus the paired Provision request-guard templates.
The shape of a detector
scan_nginx is a single-pass batch job with a fixed skeleton, and every
detector — existing or new — plugs into the same six stages:
- Defaults assigned as plain script-level
_NGINX_*variables (scan_nginx.sh:64-375). - Config override:
/root/.barracuda.cnfissourced once (:381-386), replacing any value it sets — a plain assignment, never a merge. - Validation: each numeric/regex override is range-checked and reverted to
the built-in default on a bad value (
:397-457), so userland config can never break arithmetic or silently disable a detector. - Per-line tally: the main loop reads the log incrementally and calls the
detector's
_track_*helper on each surviving line, accumulating intodeclare -Aassociative arrays (:471-531, loop:1757-1931). - Post-loop handoff: after the loop closes, a
_handle_*/_check_*function evaluates the accumulated arrays and calls_block_ipfor offenders (:1945-1972). - Output:
_block_ipwrites the IP toweb.log(:663-694); the external ban pipeline turns that into a CSF ban on its own schedule.
The detectors are deliberately independent — separate arrays, separate handlers, separate config blocks — so one can be added, retuned or disabled without touching another. Five detectors issue bans (per-IP weighted scorer, shared-UA DDoS, UA-burst fleet, path-flood, HTTP/1.0 auth-spam) and two Tier-B paths detect/alert/snapshot without banning (distributed-i18n-flood, FPM-saturation).
Incremental byte-offset log reading
The window each pass scores is whatever appended since the last pass, tracked
by byte offset in /var/log/scan_nginx_lastpos (:1733-1755):
_OFFSET_FILE="/var/log/scan_nginx_lastpos"
_last_offset=0
[[ -f "${_OFFSET_FILE}" ]] && _last_offset=$(< "${_OFFSET_FILE}")
_current_size=$(stat -c %s "${_log_file}")
# rotate/truncate guard: file shrank below saved offset -> start over
(( _current_size < _last_offset )) && _last_offset=0
if (( _last_offset == 0 )); then
exec 3< <(tail -n "${_NGINX_DOS_LINES}" "${_log_file}") # baseline
else
exec 3< <(tail -c +$(( _last_offset + 1 )) "${_log_file}") # new bytes only
fi
- Baseline vs delta. On the first run, or after a rotate/truncate reset, the
last
_NGINX_DOS_LINES(default 1999) lines are taken as a one-shot baseline. Every subsequent run reads only the new bytes since the saved offset —tail -c +N, nottail -n. - Rotate/truncate safety.
_current_size < _last_offsetmeans the file was rotated or truncated; the offset resets to 0 and the next read re-baselines. Without this guard a rotated log would seek past EOF and score nothing. - Offset write. After the loop closes the descriptor (
exec 3<&-), the current end-of-file size is written back as the next run's start (:1936-1939). The write is unconditional on the file existing, so a pass that read nothing still advances the offset. - Why this shapes tuning.
nginx_guard.shlaunches the scorer ~10× per minute, so a normal delta is only ~5 s of appended lines. Aggregate thresholds (shared-UA, path-flood) are sized against that window, not a per-minute or per-hour rate — a value that looks high per-hour is often one popular browser's 5 s of traffic. This is the single most common source of over-ban regressions; see Detector defaults are window-relative.
The same byte-offset-tail idiom is reused for the FPM error logs
(_check_fpm_saturation, :1599-1634) with its own per-file position map — see
Byte-offset tailing beyond access.log.
Cross-run sliding-window state files
A per-run window is blind to a slow attacker — one request per IP every few
minutes never accumulates inside a single 5 s delta. Two ban detectors and one
Tier-B path therefore persist state across runs in plain pipe-delimited files,
all under /var/xdrago/monitor/log/:
| State file | Owner | Window var (default) | Bucket record |
|---|---|---|---|
http10_auth.window |
_handle_http10_auth_flood |
_NGINX_HTTP10_AUTH_WINDOW (600 s) |
IP\|epoch\|count |
i18n_flood/window.state |
_handle_i18n_flood |
_NGINX_I18N_FLOOD_WINDOW (120 s) |
vhost\|epoch\|req\|slow\|err\|c444 |
i18n_flood/fpm_maxchildren.pos |
_check_fpm_saturation |
(byte offsets, no time window) | file\|size |
All three share one prune-merge-write shape (_handle_http10_auth_flood,
:1681-1727; _handle_i18n_flood, :1542-1594):
_now="$(date +%s)"; _cut=$(( _now - _WINDOW ))
_tmp="${_state}.$$"; : > "${_tmp}" # per-PID temp, race-safe
# 1. carry forward unexpired buckets
while IFS='|' read -r _key _e _c; do
[[ -n "${_key}" && "${_e}" =~ ^[0-9]+$ ]] || continue
(( _e > _cut )) || continue # prune expired
printf '%s|%s|%s\n' "${_key}" "${_e}" "${_c}" >> "${_tmp}"
_W["${_key}"]=$(( ${_W["${_key}"]:-0} + _c )) # rebuild windowed total
done < "${_state}"
# 2. append this run's tallies with the current epoch
for _key in "${!_RUN[@]}"; do
printf '%s|%s|%s\n' "${_key}" "${_now}" "${_RUN[$_key]}" >> "${_tmp}"
_W["${_key}"]=$(( ${_W["${_key}"]:-0} + _RUN[$_key] ))
done
mv -f "${_tmp}" "${_state}" # atomic replace
# 3. evaluate _W against the threshold, ban / alert
Design invariants to preserve when you extend this:
- Prune on read. Expiry is enforced every pass by dropping buckets older
than
_cut; there is no separate reaper. A run that reads the window is what ages it out. - Write to a PID-suffixed temp, then
mv -f. Ten overlapping passes per minute mean two scorers can touch the file at once; the.$$temp plus atomic rename is the only thing keeping the window from interleaving. A new windowed detector must copy this, not append in place. - Every field is re-validated on read (
_e =~ ^[0-9]+$,:-0defaults). The file is userland-adjacent (an operator cancat/edit it); a corrupt row is skipped, never fatal. - The window is a ban-decision input, not a ban ledger.
web.log/scan_nginx.archive.logare the ban record; the window only decides whether the threshold is crossed this pass.
For the HTTP/1.0 detector the windowed per-IP totals are additionally summed per
/24 (_net="${_ip%.*}", :1707-1711) so a slow trickle spread across a CIDR
block trips _NGINX_HTTP10_AUTH_CIDR_THRESHOLD even when no single IP reaches
_NGINX_HTTP10_AUTH_IP_THRESHOLD — but only IPs actually seen in the window
are ever banned, never an unseen address in the /24.
Byte-offset tailing beyond access.log
_check_fpm_saturation (:1599-1634) reuses the offset technique against a
glob of files rather than one log. It keeps a file|size position map
(fpm_maxchildren.pos), and for each file matching _NGINX_FPM_ERR_GLOB
(default /var/log/php/php*-fpm-error.log) it counts only the _NGINX_FPM_SAT_PATTERN
(reached max_children setting) lines appended since last run:
for _f in ${_NGINX_FPM_ERR_GLOB}; do # unquoted: glob expands
_sz="$(stat -c %s "${_f}")"
_off="${_POS["${_f}"]:-0}"
(( _sz < _off )) && _off=0 # per-file rotate/truncate guard
(( _sz > _off )) && _new="$(tail -c +$(( _off + 1 )) "${_f}" \
| grep -c -- "${_NGINX_FPM_SAT_PATTERN}")"
printf '%s|%s\n' "${_f}" "${_sz}" >> "${_tmp}"
done
mv -f "${_tmp}" "${_posfile}"
BOA's minutely php.sh monitor tails the same logs for the same string with
the same offset technique, but emits only a plain raise-pm.max_children
capacity NOTE; this trigger pairs the alert with attack forensics (a box-wide
* snapshot). If you add a third reader of these logs, give it its own
position file — sharing one offset map between two readers double-consumes lines.
Resolving the real client IP — never the spoofable XFF
Score the real client, never a forwarded-for token. The log records the IP chain
as $proxy_add_x_forwarded_for (the X-Forwarded-For values with $remote_addr
appended last). scan_nginx acts on only the last token — the value Nginx's
realip module rewrote $remote_addr to (:1790-1809):
_last_raw="${_ip_array[$(( ${#_ip_array[@]} - 1 ))]:-}"
if _validate_ip "${_last_raw}" \
&& [[ ! "${_last_raw}" =~ ^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then
_REAL_IP="${_last_raw}"
else
_REAL_IP=""
fi
_PROXIES_ARRAY=() # earlier chain entries are NEVER ban candidates
- The earlier X-Forwarded-For entries are client-supplied and spoofable (a client can prepend a forged public IP), so the proxy array is left deliberately empty — upstream tokens are never scored.
- The real client must be a valid, public IPv4 or IPv6 address; private ranges
are skipped for both families. An IPv4 last token is scored and banned via CSF as
before. An IPv6 last token — which can only be a realip-recovered client from a
trusted proxy (BOA disables IPv6 server-side and pins realip to the Cloudflare
ranges, so no direct v6 peer or spoofable chain entry can appear here) — rides the
same family-agnostic detectors and is banned via the nginx-native IPv6 path
(
_block_ip→web6.tempban→nginx_deny6.sh→ the$is_bannedgeo), because CSF is IPv4-only. Gated by_NGINX_V6_BAN_DETECT(defaultYES; setNOto restore the earlier IPv4-only behaviour). - The realip trust chain is a hard prerequisite. If a customer's own reverse
proxy is not in
set_real_ip_from, every request through it logs the proxy's address as the last token, and the scorer bans that one IP for all the traffic behind it — the whole site. A new detector that keys on IP must consume_REAL_IPfrom this resolution, never re-parse the chain itself. Tier-B snapshots parse the realip'd client field for the same reason: they rank real clients, never the CF/PX0 edge.
Exemption gate — ordering before detectors
The exemption gate runs at loop scope, before any detector's tally, so a
continue skips every detector at once (:1836):
_is_ignored_request "${_line}" && continue # exempts all detectors
Ordering is load-bearing. The webhook/API over-ban class (banning a provider's
rotating pool on its shared UA) is a DDoS-UA failure, not a per-IP one — a
per-IP-only skip would miss it. Because the gate precedes _process_ip,
_track_ua_ip, _track_ua_burst, _track_path_flood, _track_i18n_flood
and _track_http10_auth, one gate covers them all. The files.* host skips
(:1821-1828) sit at the same scope for the same reason.
_is_ignored_request (:760-790) is laundering-proof by construction, and its
parse is the template every request-line matcher in the script copies:
_after="${_line#*\"*\"}" # drop leading "IP-chain" quoted field
_req="${_after#*\"}"; _req="${_req%%\"*}" # _req = METHOD URI PROTO
case "${_req}" in [A-Z]*" /"*" HTTP/"[0-9]*) : ;; *) return 1 ;; esac
_uri="${_req#* }"; _uri="${_uri%% *}"; _uri="${_uri%%\?*}" # bare path
[[ "${_uri}" == /* && "${_uri}" != *".."* && "${_uri}" != *%* ]] || return 1
for _p in ${_NGINX_DOS_IGNORE_PATHS}; do
[[ "${_uri}" == "${_p}" || "${_uri}" == "${_p}"/* ]] && return 0
done
- It parses the real
$requestURI positionally — not a substring search over the whole line — so a token smuggled into a User-Agent, Referer or query string cannot forge an exemption. - It requires a full
METHOD /path HTTP/xshape, strips the query string, and refuses any URI containing..or a%escape (an encoded traversal like/shopify/webhook/%2e%2e/wp-login.phpis scored, not exempted). - It is status-agnostic — a
444on an exempt path is skipped too, which is what stops a banned webhook IP's retries from re-feeding its own score.
_track_http10_auth (:1648-1671) reuses the identical positional,
traversal-rejecting parse to select HTTP/1.0 and match the auth-path ERE — so
neither signal can be faked from an earlier field. Copy this parse for any new
request-line matcher; do not fall back to a whole-line regex.
The function-local IFS gotcha
The script sets a global IFS=$'\n\t' at the top (:48) — no space. Any helper
that word-splits a space-separated value therefore splits nothing under the
inherited IFS: the value collapses to one token and the loop matches nothing,
silently. The symptom is a detector that passes every interactive test yet
exempts/bans nothing in the running script.
Two idioms fix it, both present in the source; use one in every space-splitting helper you add:
-
Function-local declaration — set
IFSin thelocalline so it is scoped to the function and restored on return._is_ignored_request(:766) and_track_http10_auth(:1649):local _line="$1" _after _req _uri _p IFS=$' \t\n' -
Save/restore around the split — for the aggregate handlers that split mid-function (
_handle_ddos_blocking:1050-1054,_track_ua_burst:1162-1165,_handle_path_flood_blocking:1300-1303; and the watch-list parse:1443-1445):local _SAVE_IFS="${IFS}" IFS=' ' # ... split _NGINX_*_LIST ... IFS="${_SAVE_IFS}"
When a space-split helper "matches nothing", suspect the inherited IFS before
re-auditing the matching logic — the classic works-in-test, fails-live signature.
The main-loop IFS=',' and IFS='|' reads are already function/subshell-scoped
via read, so they don't leak; the trap is specifically a bare for x in $list
over a space-separated variable.
Post-hoc latency — the scorer never blocks in-request
scan_nginx is a post-hoc analyser: it runs on a cadence, off the request
path, and cannot block a request in flight. Its job is to decide who is banned;
enforcement is real-time, downstream, once the ban reaches the $is_banned geo
the request guards read.
The consequences shape every design choice here:
- A burst is scored after it completes — the fast-ban weights (a
_NGINX_DOS_STOPmatch adds the whole_NGINX_DOS_LIMITat once) shorten the score needed to cross the threshold, not the ~5 s cron latency to the next pass. - Truly distributed attacks that arrive faster than the pass cadence are the reason the aggregate and cross-run detectors exist — they catch in aggregate what no single fast pass can.
- The one exception to "never touches the firewall in-line" is the optional
/etc/boa/.instant.csf.block.cnfmarker, which makes_block_ipalso issue an immediatecsf -td 900on 80/443 (:689-692), shaving one hop off the pipeline — but that still fires from the post-hoc pass, not the request. - A new detector must therefore stay O(lines) and cheap: it runs inside a loop
that fires ten times a minute. Per-line trackers are gated on a single
_*_ONflag (_I18N_ON,_H10_ON,_UAB_ON,:1456-1472) precisely so the hot loop pays nothing when the detector is off. Wire your tracker behind such a flag.
The load-pause pass — the IDS no longer goes dark
nginx_guard.sh (nginx_guard.sh:81-108) branches on the load-pause pidfiles.
On the normal path — neither /run/max_load.pid nor /run/critical_load.pid
exists — it launches the scorer 10× per minute (5 s apart), reloading Nginx first
if access.log is missing or empty:
if [ ! -e "/run/max_load.pid" ] && [ ! -e "/run/critical_load.pid" ]; then
[ -s /var/log/nginx/access.log ] || service nginx reload
for _iteration in {1..10}; do
nohup ${_monPath}/scan_nginx.sh > /dev/null 2>&1 &
sleep 5
done
else
# load-pause: ONE bounded pass, no 10x fan-out, no sleep loop
nohup ${_monPath}/scan_nginx.sh > /dev/null 2>&1 &
fi
The else branch is the point: previously the entire launch was gated behind
"no load-pause", so the IDS stopped scanning exactly when a flood was strong
enough to trip the watchdog's Nginx stop — offenders stayed unbanned and
re-saturated the box on resume. Now a single bounded pass runs while paused.
It can neither lift the pause nor add the normal path's web-tier load, because
_block_ip bans via CSF and appends to web.log without reloading Nginx, and
scan_nginx itself has no load-pid self-gate, so the pass actually runs.
minute.sh re-invokes the guard each paused minute, giving one more ban pass per
minute.
How a new detector plugs in
Concretely, to add a banning detector, mirror the HTTP/1.0 or UA-burst detector:
- Config defaults — add
_NGINX_<NAME>_*script-level variables in the defaults block (:64-375). If the detector is on by default use a_NGINX_<NAME>_DETECT="YES"string flag with a documented per-box opt-out. - cnf override + validation — the single
source "${_CONFIG_FILE}"already loads any override. Add fail-closed validation (:436-457): numeric knobs=~ ^[1-9][0-9]*$ || _default; a regex knob test-evaluated with[[ "probe" =~ ${_RE} ]] 2>/dev/null; (( $? > 1 )) && revert. A malformed override must revert to the default with a warning, never silently disable the detector. - Arrays + gate flag —
declare -Ayour tally arrays alongside the others (:471-531); set_<NAME>_ON=1from theDETECTflag (:1456-1472) so the per-line tracker is one branch when off. - Per-line tracker — call
_track_<name>inside the main loop, after the exemption gate and realip resolution, gated on_<NAME>_ON. Consume_REAL_IP; parse the request line with the positional exemption-gate parse, never a whole-line regex. - Threshold vars + handoff — add a
_handle_<name>after the loop (:1945-1972). It runs after the per-IP, DDoS, UA-burst and path-flood passes so_BANNED_IPSis fully populated and you avoid double-banning. Ban through_block_ip— it is the keystone guard: it re-checks_is_whitelisted_ip(thecsf.allowmap) on every call and seeds the in-run_BANNED_IPScache so an IP is never written twice. - web.log ban handoff —
_block_ipappendsIP # [xSCORE] TIMESTAMPtoweb.logandscan_nginx.archive.log(:679-680); the externalweb.log→guest-fire→guest-waterpipeline converts that into the CSF ban and the$is_bannedgeo on its own schedule. Your detector's job ends at_block_ip.
Also honour the three whitelist layers on every ban path: _is_whitelisted_ip
(CSF allow, in _block_ip), _ALLOWED_IPS (/root/.local.IP.list, skipped at
scoring), and the session shields (_is_logged_in active-SSH, and the box's own
_MYIP). A Tier-B detect-only path (no per-IP ban) skips step 5's _block_ip
call and instead writes a log line plus a _i18n_snapshot forensic record.
Detector defaults are window-relative
Aggregate thresholds are sized against the ~5 s delta window, not a human's
sense of "a lot of requests". The retune that closed the am095/kwestiasmaku
over-ban class raised the shared-UA and path-flood defaults sharply
(:84-99, :154-194) because the most common mobile-browser UA on a busy
site is shared by far more than the old 20-IP floor within a single window — a
popular browser, not a botnet (a real distributed botnet randomises its UA per
IP). The load-bearing rule for anyone touching these numbers, straight from the
source comments: tune tighter, never looser, and only on a real report. The
UA-burst detector's bad-status ratio (_NGINX_UA_BURST_BAD_PCT) is the
false-positive keystone that lets its IP floor sit far below the DDoS-UA one
without re-opening the over-ban class — a legitimate shared browser UA is ~all
200/304 and never trips.
Do not quote specific default values in code comments or living docs — they move on retune. State the rule (window-relative, tighter-only) and let the variables reference carry the current numbers.
Paired-template carry-verbatim caveats (request-guard maps)
The scorer bans; the request guards enforce — and those live in the
Provision Nginx templates, not in bash. Several detectors here are two halves of
one mechanism split across scan_nginx.sh and the templates, and the template
side follows a strict declare-then-enforce discipline you must not break when you
touch it.
- Two-stage map idiom. A guard variable is declared in the http block by
server.tpl.php(server.tpl.php:458-462for$is_cms_probe;:924-952for the$boa_i18n_guard/$boa_i18n_path/$boa_is_anon→$boa_i18n_anon_keychain) and enforced per request invhost_include.tpl.php.server.tpl.phpre-renders the http block before the vhosts, so every deployed vhost can reference the map. Never remove or rename a deployed map variable — dropping one turns the nextnginx -tinto a fleet-wide reload failure. Add, never replace; the idiom exists precisely songinx -tnever sees an undefined variable. $is_cms_probe(server.tpl.php:458-462) matches WordPress/Joomla/phpMyAdmin admin paths as whole path segments only (/wp-content-strategyand/site-administratordeliberately do not match), andvhost_include.tpl.phpreturns444when it is set — before Drupal bootstraps. That444is then scored byscan_nginx's per-IP 444 counter, so the edge guard both sheds load and makes the offender visible to the firewall layer.admineris excluded (BOA ships Adminer); generic auth words (login,signin,admin,user,account) are excluded because they collide with real customer namespaces — that distributed generic-auth-word tail is banned in aggregate by the UA-burst detector instead.- The Tier-A i18n cap (
limit_conn_zone $boa_i18n_anon_key,server.tpl.php:120; enforced atlocation = /index.php) is the real-time companion to the Tier-B i18n-flood detector: the444s the cap sheds are the detector's earliest trip signal (_NGINX_I18N_FLOOD_C444_THRESHOLD). The cap keys on a constant per vhost, not per IP, so it never appears incsf -t/csf -g— it caps blast radius rather than chasing rotating IPs. - Carry any shared change verbatim into the self-contained templates. The
subdir vhost template does not
includethe shared common file — it inlines its own body — so a guard-chain fix invhost_include.tpl.phpdoes not reach it. Mirror the change there too, verbatim, or subdir vhosts silently diverge. Likewise the SSL vhost path reuses the same include, so it inherits the guard chain automatically — but the subdir copy is the one that bites. - BOA
sed-patches the shipped templates on disk during the Barracuda/Octopus upgrade chain. An edit to a.tpl.phpin the tree only lands if it survives (or is applied by) those patches — a template change theseds would undo is silently reverted on the next upgrade. When a map or guard you added does not appear on a box, check the patch set, then confirm the vhost was actually re-rendered by a Verify.
Related
- Monitor & abuse-guard internals — topic chapter — the code-level view of the monitor stack these detectors live in.
- Monitor deploy surfaces — how
scan_nginx.shandnginx_guard.shreach a box (/var/xdragolayout, cron wiring). - Nightly worker architecture — the sibling
worker family that shares the
/var/xdrago/monitor/log/state tree. - Variables reference — every
_NGINX_*default the scorer reads, at their current values. - Commands reference — the CLI surface, including the recovery tooling that clears web-IDS bans.
- Discontinued features — retired detection mechanics and superseded IDS behaviour.