BLA
BLA (Blind LLM Agents) is the open-source Blue Defense MCP and CLI built to protect software artifacts against automated AI reverse-engineering workflows.
翻譯準備中
Cloudflare-aware abuse swatter for cPanel + CSF: scores web-log IPs on behavioral signals + threat-intel and blocks attackers on the right plane — CSF for direct-to-origin, Cloudflare WAF for proxied — so it never firewalls a CF edge.
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗A Cloudflare-aware abuse swatter for cPanel + CSF servers — and any Linux host
with ipset/iptables, since CSF is optional (see Firewall backend).
Swatter reads your web-server logs, scores every IP on weighted behavioral
signals plus optional threat-intel, and blocks the malicious ones — automatically,
on the right firewall plane. Distributed scanners, credential brute-force,
/.env and /.git probes, exploit fuzzers, and request floods get swatted before
they spike your load. Repeat offenders earn permanent bans.
It runs as a small set of Bash + awk scripts on a cron. No agent, no daemon, no compiled dependencies, no phone-home.
$ swatter top
IP SCORE OFFN TEMP PERM CHANNEL LAST
45.146.165.10 92 4 3 1 csf high_badpath_repeat
104.152.52.20 78 1 1 0 csf scanner_profile
193.32.162.40 85 2 2 0 cloudflare request_flood
SCORE is the worst score ever seen for that IP — a detection fact, recorded in
report mode too. OFFN/TEMP count enforced actions only, so in report mode
they read 0 while SCORE and LAST still show what was detected.
TEMP is a lifetime count and is not the recidivism ladder's number: the
ladder counts temps inside REPEAT_WINDOW_DAYS and resets them at an operator
unblock, so an IP can show TEMP 3 here while the ladder sees 0. To ask "how
close is this IP to a permanent ban," use swatter escalate-preview — that runs
the ladder's own arithmetic.
Most "fail2ban for Apache" tools have one fatal blind spot on a Cloudflare-fronted
server. Your logs show the real visitor IP (via mod_remoteip /
CF-Connecting-IP), but the actual TCP socket is a Cloudflare edge IP. Block
that socket at your server firewall and you've just firewalled Cloudflare —
every site on the box goes dark.
Swatter is built around this. For every offender it decides:
Host header, since a proxied request always arrives from a CF edge)
→ block at CSF. Safe: the socket really is the attacker.CF_SCOPE: zone (the single zone hit) or account (every
account at once, so a vhost-rotating scanner can't roam — see below).Permanent bans are tracked per plane, and the two planes cover each other:
an IP permed at the Cloudflare edge that later shows up hitting the origin
directly (say, a webmail flood on a cPanel service port that never transits
Cloudflare) acquires the missing CSF deny instead of being dismissed as
"already blocked" — and vice versa. An offender that earns a perm while
carrying a hard-intel reputation (Spamhaus DROP, AbuseIPDB 100 — never a
legitimate visitor; threshold INTEL_HARDBLOCK_MIN) gets it on both planes
at once
(DUAL_PLANE_HARD_INTEL, default on), so direct-to-origin ports are covered
before the attacker tries them.
Cloudflare's own ranges are a hardcoded never-block set, re-checked immediately before every single block. If the range list ever goes missing or stale, Swatter fails closed — it stops issuing CSF denies entirely rather than risk an outage.
Four Cloudflare postures (CF_MODE):
| Mode | Your setup | Via-CF offenders | Direct offenders |
|---|---|---|---|
auto | Default — detect and adapt | safe plane unless proven bare | CSF |
direct | Behind CF, Swatter is the defense | challenged/blocked via API | CSF |
skip | Behind CF, you run your own WAF rules stack | logged, left to your edge rules | CSF |
off | Not behind Cloudflare at all | n/a — everything is direct | CSF |
Either/or, out of the box. auto (the default) detects whether the server
sits behind Cloudflare — it resolves your own vhosts and checks them, your
inbound sockets, and your web-server config against the Cloudflare ranges — and
picks the posture for you. Detection runs at install, on refresh-feeds, and on
test-config, never per-scan, so the cron path stays cheap. It is safety-
biased: only a confident "this box is not behind Cloudflare" reading turns the
CF plane off; anything uncertain keeps classification on (the safe plane), so
auto can never misroute a proxied socket to CSF. A box with no Cloudflare
domains at all therefore works as a plain CSF auto-blocker with zero Cloudflare
config — and it actually issues blocks: the fail-closed rail (which disables CSF
denies when the CF range list is missing) only engages on a CF-fronted box,
where the list is needed to tell an edge socket from a direct one.
Detection re-runs daily (the refresh-feeds cron), so auto adapts if a box
moves on or off Cloudflare. It identifies the box's domains from the
domlog filenames, so on a plain (non-cPanel) Apache/nginx server with only a
single shared access log it can't prove the box is bare and stays on the safe,
classification-on posture. On such a server that isn't behind Cloudflare, set
CF_MODE="off" explicitly (test-config will tell you when it couldn't decide).
If you maintain your own Cloudflare WAF rules, rate limits, and login-page
protections, set skip explicitly: Swatter stays out of the realm you already
manage but still swats raw-IP scanners at CSF and logs what it would have
challenged. Do not use off on a proxied server to mean "hands off
Cloudflare" — off disables the plane classification itself, so proxied
visitors (your own customers included) can be misrouted to a CSF deny that also
takes out their mail and cPanel access. (Setting any explicit mode skips
detection.)
The closest tool in spirit is CrowdSec with its Cloudflare bouncer, and it's good — if you want a daemon, a central reputation network, and a component per concern. Swatter is the other end of the spectrum: bash + gawk + cron, no resident process, cPanel/CSF-native, and the direct-vs-proxied classification built into every decision rather than bolted on as a bouncer.
Each IP is scored 0–100 over a sliding window from a weighted blend of signals:
| Signal | What it catches |
|---|---|
| Request rate | hammering / floods |
| Error ratio (4xx/5xx) | scanners generate errors |
| Error burst (403/404/444) | brute-force & path fuzzing (srcset-shaped requests excluded below status 400 plus 404 — srcset_exempt; static-asset and srcset-shaped 403s excluded as 403_exempt) |
| URL fanout | path enumeration / scanning |
| Bad-path hits | /.env, /.git, wp-login.php, xmlrpc.php, /cgi-bin/, shell drops, … |
| User-agent | sqlmap, nikto, zgrab, empty UAs |
| POST flood | login / xmlrpc / comment spam |
| No-vhost / raw-IP | only attackers hit you by IP |
| Reputation | AbuseIPDB, Spamhaus, IPsum (optional) |
A pure average dilutes a focused attack (a credential brute trips only a few
signals), so Swatter pairs the blended score with decisive floors: a
/.env probe, a sustained flood, a broad scanner profile, or repeated hits on a
sensitive endpoint are each independently sufficient to act — and every decision
records why, with the evidence, to /var/log/swatter/decisions.jsonl.
Tune everything (weights, thresholds, bad-path table) in
/etc/swatter/swatter.conf and /etc/swatter/badpaths.conf.
For IPs that already look suspicious, Swatter can corroborate against external reputation feeds before acting:
malicious raises; RIOT
(known business services — Google/Slack/CDNs) is a soft never-block guard;
benign scanners are logged but still judged on behavior.swatter refresh-feeds, confidence-tiered so noisier feeds corroborate
less. On by default.ABUSEIPDB_KEY); add abuseipdb_blocklist to INTEL_PROVIDERS.Reputation only ever raises a borderline score — it never blocks on its own (with one deliberate exception: a hard-intel hit on an already-blocked offender widens the block to both planes, per above), and a failed or offline lookup is simply ignored.
Swatter runs fully with zero API keys — IPsum, Spamhaus, and the keyless list feeds need none, and every keyed provider degrades silently to "no data" when its key is unset. Add a key only for the providers you want; each is free:
| Provider | Cost | Get a key |
|---|---|---|
| AbuseIPDB | Free (1,000 checks/day) | Create an account at abuseipdb.com → Account → API → generate a key. Set ABUSEIPDB_KEY. |
| GreyNoise | Free (Community) | Sign up at greynoise.io → Account / Profile → copy the API key. Set GREYNOISE_KEY. |
| Project Honey Pot | Free (membership) | Join projecthoneypot.org → request an http:BL access key (12 chars) from your member dashboard. Set HTTPBL_KEY. Needs dig/host installed. |
Put keys in your own /etc/swatter/swatter.conf (root-only, 0600) — never
in the repo, never shared — or, better, point each provider at a root-only key
file (ABUSEIPDB_KEY_FILE, GREYNOISE_KEY_FILE, HTTPBL_KEY_FILE; one
line, 0400, and the file wins over an inline key) so the secret never sits in
the conf at all. The shipped swatter.example.conf has every key set
to empty by default. At runtime, keys never appear on a curl command line
(where any local user could read them out of ps): every credentialed request
passes its secret through a per-call 0600 config file (curl -K), kept under
Swatter's private state directory — not /tmp — and swept by a daily GC so
nothing lingers even if a run is killed mid-request. The one
exception is Project Honey Pot's http:BL, whose protocol embeds the key in the
DNS query itself — that key is lookup-only by design.
Swatter needs no alerting config to run. All channels are opt-in; omit any you don't need. Credentials go in root-only files on the server — never in the repo.
| Channel | What to set | Where to sign up |
|---|---|---|
| Email (SendGrid / Brevo) | ALERT_EMAIL (recipient) — sent via the nightly report mailer, so it follows REPORT_METHOD + that method's key file | sendgrid.com (free 100/day) or brevo.com (free 300/day). Create an API key with Mail Send permission only. |
| Twilio SMS | ALERT_SMS_TO, TWILIO_SID, TWILIO_FROM, TWILIO_TOKEN_FILE | console.twilio.com — free trial credits. Copy Account SID and Auth Token from the dashboard; write the Auth Token to a 0600 file and set TWILIO_TOKEN_FILE. |
| Webhook (Slack, Discord, PagerDuty, …) | ALERT_WEBHOOK_URL, ALERT_WEBHOOK_FORMAT | Slack: App directory → Incoming Webhooks. Discord: channel Settings → Integrations → Webhooks. Set ALERT_WEBHOOK_FORMAT to slack, discord, or raw-json; auto sniffs the URL. |
ALERT_REPEAT_TTL (default 6 h) suppresses repeat alerts for the same IP so a
sustained attack doesn't flood your inbox or phone.
SMS also pages on the nightly report's status: ALERT_SMS_GRADES (default
RED) texts you when the digest lands on 🔴 RED — add YELLOW to be paged on
non-fatal error floods too.
swatter test-config reports which channels are active.
By default Swatter issues direct blocks through CSF (DIRECT_BACKEND=csf).
On servers where CSF is not installed, or for lower-latency bulk drops, set
DIRECT_BACKEND=ipset to use kernel ipset + iptables DROP rules instead.
# In /etc/swatter/swatter.conf:
DIRECT_BACKEND="ipset"
IPSET_SAVE_FILE="/etc/swatter/ipset.save" # written by setup-ipset; restore at boot
After changing to ipset, run the one-time setup:
swatter setup-ipset # creates swatter4/swatter6 ipsets + iptables DROP rules
setup-ipset is idempotent — safe to re-run after a reboot or kernel upgrade.
The two backends can coexist: CSF continues to manage its own deny list; the
ipset sets are separate. After a reboot, restore the ipset with the save file
(ipset restore < "${IPSET_SAVE_FILE}"); the installer notes this if
DIRECT_BACKEND=ipset is set.
IPv6 is supported across scoring, exact-prefix allowlisting, ASN/hosting boost
(origin6 lookups), and blocking (the swatter6 set + ip6tables DROP rule).
Install ip6tables so v6 blocks actually enforce — swatter test-config warns
if it's missing. Two signals remain IPv4-only by nature of their data
sources: the Project Honeypot provider and the live-socket direct-evidence check.
The direct-vs-proxied classifier (above) is reactive: it scores a
direct-to-origin Cloudflare-bypass offender after it shows up in the logs,
which a fast valid-Host flood can outrun. Origin lock is the structural
complement — an inline L3 firewall rule that only lets Cloudflare edge ranges
reach the locked web ports (ORIGIN_LOCK_PORTS, default 443), so bypass
traffic is dropped at the socket before it ever becomes a log line. The classifier still scores offenders from
logs; the lock closes the door instead of scoring who walked through it. The two
are independent and conflict-free (different chains, different purpose).
It is off by default and config-driven — it carries no IPs, hostnames, or
allow assumptions of its own. The Cloudflare ranges come from the same
CLOUDFLARE_IPS_FILE (/etc/swatter/cloudflare.cidr) that refresh-feeds
already maintains; everything policy-specific (whether to enforce, which extra
sources to admit) lives in your own /etc/swatter/ config.
ORIGIN_LOCK is a three-state ladder, and you climb it deliberately:
ORIGIN_LOCK | What apply installs |
|---|---|
off (default) | nothing — a fresh install enforces no restriction |
log | ACCEPT Cloudflare edges → web ports, then a rate-limited LOG (prefix ORIGIN-LOCK: ) of everything else. No drop. |
drop | the same ACCEPT + LOG, then a DROP of the remainder |
⚠️ Run
logmode first. Read the would-be-drops withpreflight. Allowlist every legitimate source. THEN enforce. A direct-to-origin lock that drops blind can blackhole real visitors to a non-proxied (grey-cloud) vhost, your monitoring, or your own health checks.dropis guarded for exactly this reason:applyrefuses to install aDROPwithout first runningpreflightand getting an interactive confirmation (or--yes/--force). The most embarrassing failure of any inline lock is firewalling your own customers — so the tool makes enforcing blind hard to do by accident.
Rules are built accept-first, drop-last, so a mid-build failure can never
leave a DROP standing without its preceding ACCEPTs (fail-open). If
cloudflare.cidr is missing, empty, or yields fewer than a sane minimum of
ranges, apply installs no restriction at all and logs why — an empty
allowlist plus DROP would firewall the entire internet.
Locking :80 breaks HTTP certificate validation — leave it out of
ORIGIN_LOCK_PORTS if you rely on ACME HTTP-01 / cPanel AutoSSL DCV.
Earlier versions shipped an ORIGIN_LOCK_ALLOW_ACME carve-out that accepted
packets containing /.well-known/ on :80; it is retired because it cannot
work: an xt_string payload match can never admit a new connection — the
TCP handshake carries no payload, so a validator's SYN hits the DROP before
any matchable packet exists (observed in production: the rule's counter stayed
at zero while Let's Encrypt validators were SYN-dropped and renewals failed).
Validator source IPs are deliberately unpublishable, so allowlisting is not an
option either. The supported postures are ORIGIN_LOCK_PORTS="443"
(recommended — :80 serves only redirects and challenges; content stays
locked on :443) or DNS-01 validation if you must lock both ports. apply
warns on every run whose port list includes 80. The old toggle is ignored,
and teardown still removes the legacy rule from boxes an older version
configured.
cloudflare.cidr carries both v4 and v6 ranges (refresh-feeds fetches both
families). apply builds the v4 path with iptables and the v6 path with
ip6tables, gated on ip6tables being present. If it is absent the v6 path
logs a loud warning and leaves v6 web ports uncovered rather than failing —
install ip6tables so a dual-stack host doesn't keep a silent direct-to-origin
hole on v6.
A single idempotent code path (apply) serves both worlds; the execution
context is explicit, never guessed:
swatter origin-lock apply --hook=csf --yes — into /etc/csf/csfpre.sh (created if absent). csfpre.sh
runs before CSF builds its chains on every csf -r, so the lock re-applies and
is ordered ahead of CSF's blanket TCP_IN accept (measured durable across
csf -r/csf -ra/lfd restart under FASTSTART=1/LF_IPSET=1 — csfpre is the
sole carrier, no systemd needed). The --yes is required: at csf -r the hook
runs non-interactively, and DROP mode would otherwise abort at the confirm
guard and install nothing (setting ORIGIN_LOCK=drop is the operator's
consent). CSF already supplies loopback,
established/related, and csf.allow accepts above csfpre, so in this context
apply adds only the Cloudflare/ACME accept + LOG/DROP (a redundant
established accept is deliberately avoided — it can leak handshakes under
nf_conntrack_tcp_loose=1).oneshot systemd unit
(WantedBy=multi-user.target) that runs apply at boot; an rc.local
equivalent works too. Here apply lays a safe preamble before the DROP —
ACCEPT -i lo to the web ports (local self-calls / health checks) plus
ACCEPT of allow.cidr + monitoring.cidr (the portable csf.allow
equivalent). It still adds no standing established accept; outbound-initiated
return traffic lands on ephemeral ports, never dport 80/443, so the DROP
never matches it.swatter origin-lock status reports which persistence mode is active.
| Command | Behavior |
|---|---|
swatter origin-lock apply | Converge the firewall to the configured ORIGIN_LOCK mode (including removing rules when off). The drop guard applies. |
swatter origin-lock status | Mode, ipset health (v4/v6 entry counts), live ACCEPT/DROP counters, persistence mode (csfpre vs systemd), and whether the hook is installed. |
swatter origin-lock preflight | Read-only. Classifies the would-be-drops from the log-mode sample against Swatter's intel feeds + allowlist: confirmed attacker (in a feed, safe to drop), legit (in monitoring.cidr / allow.cidr — allowlist before you drop), or unknown (review — could be a real visitor to a non-CF vhost). It reads the kernel-LOG source (journalctl -k, else /var/log/{messages,syslog,kern.log}) and notes it is a rate-limited sample, not every packet. |
swatter origin-lock disable | Full teardown: removes the iptables/ip6tables rules, the cf_origin4/cf_origin6 sets, and the persistence hooks (the csfpre line + the systemd unit), so nothing re-applies on reboot or csf -r. |
swatter test-config and swatter status surface origin-lock state (mode,
ipset health, hook installed) alongside the existing readiness checks. The lock
coexists with DIRECT_BACKEND=ipset: the offender swatter4/swatter6 sets
and the cf_origin4/cf_origin6 sets are independent and both live in INPUT
without conflict.
If your origin is a cPanel server, the lock will block its service subdomains
on :443. cPanel auto-creates cpanel., whm., webmail., webdisk.,
autodiscover., autoconfig., cpcontacts., and cpcalendars. as DNS-only
(grey-cloud) records pointing straight at the origin — so they arrive not from
a Cloudflare edge, and the lock drops them. (mail. and the MX/IMAP/SMTP records
use other ports, so the lock never touches them — and they must stay DNS-only,
since Cloudflare can't proxy mail protocols.)
Two ways to restore the HTTP ones:
https://<domain>:2083 (cPanel), :2087 (WHM),
:2096 (webmail). The lock only covers ORIGIN_LOCK_PORTS (default 443),
so the high ports are unaffected. Best for the admin panels (cPanel/WHM), which
you generally don't want publicly reachable anyway — restrict them to your own
IPs in csf.allow.webmail..The 526 gotcha when proxying. cPanel AutoSSL secures a primary domain's
service subdomains, but not an addon domain's — for an addon's webmail., the
origin presents the server-hostname certificate, which Cloudflare Full (strict)
rejects with HTTP 526. Fix it without weakening the zone: add a per-hostname
Configuration Rule that sets SSL to Full (not strict) for the service-sub
hostnames, leaving the apex on strict. Cloudflare then accepts the origin's
hostname cert while still serving a valid edge certificate to visitors.
Certificate renewal keeps working behind the lock because the default
ORIGIN_LOCK_PORTS="443" leaves :80 — where HTTP DCV lands — unlocked (see
"Locking :80 breaks HTTP certificate validation" above), and a proxied
record's validation traffic arrives via Cloudflare edges the lock already
accepts.
Set ABUSEIPDB_REPORT=true in /etc/swatter/swatter.conf to report each
permanent ban back to AbuseIPDB (reuses your existing ABUSEIPDB_KEY). Off by
default — opt in deliberately.
ABUSEIPDB_REPORT="true"
ABUSEIPDB_REPORT_TTL=900 # don't re-report the same IP within N seconds
ABUSEIPDB_REPORT_MIN_ACTION="perm" # report only high-confidence perm bans (default);
# a first-seen temp is never reported outward
Only permanent bans are reported by default (ABUSEIPDB_REPORT_MIN_ACTION) —
a first-seen temp block or a fleet-corroborated temp is a lower-confidence signal,
so it's kept off the wire rather than pushed as reputational harm to a third party.
Set "temp" to report temps too.
swatter test-config shows whether reporting is on and warns if ABUSEIPDB_KEY
is unset (reporting would be inert).
Two ways to share confirmed offenders across a multi-server fleet:
Swarm hub (automated) — self-host the Swatter Swarm Hub, a
tiny Cloudflare Worker + D1 database that fits in the free tier. Each box
publishes its confirmed permanent bans after every scan and consumes the
merged, time-decaying fleet feed as the swarm intel provider. Enrollment
(swatter swarm enroll) issues each box its own write token, so one
compromised box can't impersonate another or poison the fleet under a shared
credential. By default a fleet sighting only boosts an already-suspicious
local score (SWARM_ACTION=boost); set corroborated-block to pre-block an IP
once SWARM_MIN_CORROBORATION distinct hosts have independently confirmed it.
When enabled, the nightly digest gains a Swarm plane showing what the box gave
and got. Setup, hub API, and token model: hub/README.md.
Manual export/import — no hub, just files:
swatter export-bans /tmp/bans.txt # write perm-ban list to file (or stdout)
swatter import-bans /tmp/bans.txt # block each listed IP as perm on another host
import-bans skips IPs on the never-block list, any malformed entries, and
catastrophic targets (a /0 or an unspecified address like 0.0.0.0) — safe
to pipe from an untrusted source. Use export-bans in a cron and import-bans
on the receiving hosts to keep ban lists in sync across a fleet.
An IP that keeps earning fresh temp blocks — instead of going quiet or getting
corrected — graduates to a permanent ban. REPEAT_N (default 3) enforced temps
for the same IP inside a trailing REPEAT_WINDOW_DAYS window (default 7)
triggers the perm; a report-mode temp never counts, so watching a false
positive in dry-run for a week can't itself drive a ban the moment you flip to
enforce.
A single CRITICAL bad-path hit (say, one /.env probe) is already a temp at
any request volume, so a bare REPEAT_N would perm-ban an IP after just 3 such
probes — cheap for an attacker to trigger against a third party's address. When
every in-window temp for an IP is one of these single CRITICAL probes,
escalation instead requires REPEAT_N_CRITICAL_SINGLE (default 4) of them.
This gate is sqlite store only — it matches on the rule= stamp recorded
in the ledger, which STORE=flatfile cannot query, so on a flatfile host it is
inert and escalation falls back to plain REPEAT_N (Swatter logs a warning
when that happens). It is also inert for the first REPEAT_WINDOW_DAYS after
upgrading on any store: temps recorded before the upgrade carry no rule=
in their reason, so they never read as CRITICAL singles and the bar stays at
REPEAT_N until the pre-upgrade history ages out of the window. Note the
direction — "inert" here means the lower bar applies, i.e. it fails toward
more banning, not less, for exactly the offenders this gate exists to spare.
Widening REPEAT_WINDOW_DAYS extends that blind period to the new window
length. A
ladder perm carries the math that produced it: recidivism=<n>/<days>d in the
decision reason and evidence.recidivism in the stored evidence, alongside
rule=<decisive_rule> showing which detector actually fired. The nightly
digest reports how many of a window's perms came from the ladder, and a
tripwire (PERM_RATE_ALERT_PER_RUN, default 5; PERM_RATE_ALERT_PER_DAY,
default 15) fires an alert on the same run that crosses it, pointing at the
preview/rollback commands below. The shipped defaults are a placeholder,
not a calibration — perm volume is a property of the traffic a given host
receives, so a threshold below this host's normal spike produces an alarm
nobody trusts. Measure this host's real perm rate from the ledger (actions
in swatter.db, which does not rotate — see
docs/RUNBOOK.md section 8 for the exact query) and set
both knobs above the observed ceiling before relying on the tripwire during
an incident.
REPEAT_ENABLE (default true) is the ladder's abort lever: set it to
false and the next scan (config is read once per process, so a scan already
running finishes under the old setting) stops converting temps into new
permanent bans. It does not stop everything that permanently bans an IP.
Honeypot instant-perms, the hard-intel dual-plane leg, and a plane-upgrade
perm (an IP already perm on one plane that reappears on the other) are
independent of the ladder and keep firing while the switch is off — a
honeypot or plane-upgrade perm never carries recidivism= in the reason, so
swatter why <ip> tells those apart from a ladder perm. One exception:
the hard-intel dual-plane leg reuses its primary leg's reason verbatim
(prefixed dual-plane ), so when the primary leg was itself a ladder perm
that also met INTEL_HARDBLOCK_MIN, the dual-plane leg inherits
recidivism= too — read dual-plane ... recidivism=... as the second plane
of a ladder perm, not as evidence the switch failed. See
docs/RUNBOOK.md for the full 3am incident procedure:
disarm, confirm, and — if bans already went out — drain and roll back.
Operator gotchas:
swatter unblock, not swatter allow, clears a false positive. Only
unblock resets the recidivism ladder (it writes a watermark; temps at or
before it stop counting). allow adds the IP to the never-block set so it
can't be blocked again, but it does nothing to the temp history already on
the ledger — the count an eventual future offense would add to.REPEAT_WINDOW_DAYS (or raising
REPEAT_N) changes only how future offenses are judged; it does not undo
permanent bans the ladder already placed under the old setting. To actually
reverse them, use swatter rollback-ladder --since <ts> (below).swatter swarm purge and a 7-day TTL,
so a bad ladder perm you roll back locally may still be acted on by peers
until it expires there.CF_ACTION=managed_challenge, repeat
temp/cloudflare decisions for the same IP within a day are expected, not a
bug. A challenged IP keeps reaching the origin (it hasn't been dropped),
so it can keep tripping the scorer — and that's exactly what drives it to a
perm within two or three re-temps. Don't chase it as a false-positive pattern.# Read-only: who perms on their NEXT offense at a given window, from the ledger
# alone. No ingest, no cursor advance, no lock — safe to run against a live host
# mid-scan. sqlite store only.
swatter escalate-preview --window 30 > candidates.tsv
# Bulk-undo permanent bans the ladder placed since a given time (epoch or an
# ISO timestamp `date -d` can parse). One lock acquisition for the whole run;
# continues past a per-IP backend failure and exits non-zero if any were
# partial (unblocked in the ledger, but a firewall rule may still be live —
# check the PARTIAL lines it prints).
swatter rollback-ladder --since "2026-07-20 00:00:00" --dry-run # preview first
swatter rollback-ladder --since "2026-07-20 00:00:00" # then apply
escalate-preview writes four tab-separated columns —
ip temps_prior last_temp_utc status — plus a legend on stderr, so
redirecting stdout gives you a clean TSV.
temps_prior is the count already on the ledger, not the count that bans.
The decider counts the pending offense as well (prior + 1 >= REPEAT_N), so
an IP listed with temps_prior=2 at REPEAT_N=3 earns recidivism=3/<days>d
the next time it offends. Read the column as "one more and it's permanent".status is one-away when temps_prior == REPEAT_N-1 and at-bar when
it is already at or over REPEAT_N. Both perm on their next offense — the
list deliberately includes the one-away rows, because those are precisely the
IPs a window widening bans first.REPEAT_N_CRITICAL_SINGLE, so an offender
whose in-window temps were all single CRITICAL probes may get another temp
instead of the ban. The list errs toward over-reporting, never under.Recovery after a rollback:
ABUSEIPDB_REPORT=true (with the default ABUSEIPDB_REPORT_MIN_ACTION=perm,
ladder perms are exactly what gets reported), the customer IP you just rolled
back is publicly listed as an abuser. rollback-ladder prints a notice when
that config is on; clearing it means asking AbuseIPDB to remove the reports
(their contact form / "remove my reports" process), which Swatter cannot do
for you.Some addresses are used by many unrelated people at once — Cloudflare WARP
(104.28.0.0/16 for IPv4, plus 2a09:bac0::/32–2a09:bac7::/32 for IPv6; the
1.1.1.1 app) and consumer VPN exits. Offenses from them
are real and the scoring is right, but the identifier is not the offender:
the ladder counts offenses per address, so three bad afternoons from three
different people behind one exit escalate it exactly as if one attacker had
come back three times.
So Swatter caps them. On a match, a permanent ban becomes a ladder-maximum
temp (72h) and the perm is never placed. A capped perm is never published
either: the swarm publishes only from the permanent-ban store (and there is now
no perm there), and the AbuseIPDB report is skipped explicitly for a capped
IP, whatever ABUSEIPDB_REPORT_MIN_ACTION says — that protection must not rest
on a config value an operator may change for unrelated reasons.
This is not an allowlist. The address is still blocked, repeatedly, for as
long as it misbehaves. Do not add these ranges to allow.cidr or
cloudflare.cidr — everything in those files is a never-block, which would hand
anyone a bypass for the price of a free consumer VPN.
The exclusion covers capped perms, not every block on these ranges. The
shared-egress lookup runs on the perm branch only, deliberately, so that a DNS
lookup never lands on the hot temp path. An ordinary first-offense temp is
therefore not a capped perm and follows ABUSEIPDB_REPORT_MIN_ACTION like any
other temp. The default (perm) reports no temps at all; set it to temp and a
first-offense temp on a WARP address will be reported.
import-bans participates too: it skips shared-egress addresses, and refuses
any CIDR token that overlaps one — 104.28.0.0/16 and 104.28.0.0/24 alike.
Matching is by prefix overlap, not host-address containment, so a prefix is
tested as a prefix rather than silently matching nothing.
The policy ships enabled (SHARED_EGRESS_ENABLE defaults to true) but is
entirely data-driven, and both lists live under /etc/swatter — which a
file-by-file deploy never creates:
| File | Effect if absent |
|---|---|
/etc/swatter/shared-egress.cidr | CIDR arm inert — WARP is not capped at all |
/etc/swatter/shared-egress-asns.txt | ASN arm inert (this is the shipped default: the file ships with documentation and no entries) |
install/install.sh creates both from config/, never overwriting an existing
one — it refreshes <file>.example instead, so the shipped list is always
available to diff.
That means widening the shipped default does not reach a host that already has
the file. When these lists grow upstream (the IPv6 WARP pool was added on
2026-08-13, taking the default from 1 range to 9), an existing install keeps its
old copy and test-config still reports ENABLED — there is no warning that the
live file is now narrower than the shipped one. After upgrading, diff and merge:
diff /etc/swatter/shared-egress.cidr /etc/swatter/shared-egress.cidr.example
# merge anything missing, then re-check what is already permanently banned:
swatter shared-egress-audit
A surgical deploy that copies only bin/swatter and lib/*.sh leaves the
policy enabled and capping nothing, silently. swatter test-config prints
the state of each arm (cidr: … (9 range(s)) for the shipped default, vs
MISSING — deploy it; CIDR arm INERT), and swatter shared-egress-audit exits
non-zero rather than printing an all-clear it cannot back up when neither arm is
usable.
# Which permanent bans would the policy refuse today? (read-only, sqlite store)
swatter shared-egress-audit
# Unblock them. Does NOT allowlist — these addresses rotate between people.
swatter shared-egress-audit --fix
# Over SHARED_EGRESS_AUDIT_MAX (default 25) rows, --fix refuses until you have
# reviewed the list and re-run with --force.
swatter shared-egress-audit --fix --force
# Confirm both arms have usable data
swatter test-config
offenders.perm=1 from before this feature keeps that flag
when the cap downgrades its next block to a temp (the rollup only ever raises
it), so the firewall is correct while the ledger still says perm — and the
swarm will still publish that IP. shared-egress-audit selects on that same
predicate, so every such IP is listed and --fix clears it. Run it, and
confirm a clean result, before unfreezing publication./16 (v4) or /32 (v6) is rejected
and switches the whole CIDR arm off for that run, because a wide entry would
silently stop all permanent banning. Check swatter test-config after editing.swatter_scan_shared_caps (Prometheus) counts the
perms downgraded last run, so an over-broad CIDR line that mass-caps perms is
visible per run and not only per IP.Residual risk, accepted knowingly. Permanent bans are impossible on these
ranges, so a patient attacker can rotate through the pool and return within 72h.
On the Cloudflare plane this costs nothing — a CF "perm" is already TTL-emulated
at the same ladder maximum — so the enforcement change is confined to the CSF
plane. Set SHARED_EGRESS_ENABLE="false" to disable entirely (takes effect next
scan; does not re-ban anything already capped).
Knobs: SHARED_EGRESS_ENABLE (true), SHARED_EGRESS_CIDR_FILE,
SHARED_EGRESS_ASNS_FILE, SHARED_EGRESS_MIN_PREFIX4 (16),
SHARED_EGRESS_MIN_PREFIX6 (32), SHARED_EGRESS_AUDIT_MAX (25). Full
incident procedure in docs/RUNBOOK.md.
ASN_SIGNAL_ENABLE) — when an offender is already
behaving like an attack, originating from a datacenter/bulletproof ASN
(Team Cymru lookup) adds a small bounded boost. Legit visitors from those
networks are never penalized on origin alone.HONEYPOT_PATHS_FILE) — define a secret path no human
hits; one request = score 100 = instant permanent ban. swatter honeypot
prints a robots.txt + invisible-anchor snippet to advertise it to bots only.PERSIST_N distinct buckets within PERSIST_WINDOW_DAYS.METRICS_FILE) — a node_exporter textfile with
block counts, current offender counts, feed-staleness ages, quota use, and
fail-closed state, written atomically each scan and via swatter metrics.swatter report emails one nightly digest, delivered as a structured HTML
report (verdict line → stat tiles → per-plane tables). Each report leads with a
traffic-light status — 🟢 GREEN (all clear: blocks and origin-lock drops
live here, that's Swatter working), 🟡 YELLOW (elevated non-fatal error volume,
worth a look), 🔴 RED (a genuine fatal error — a service or app may be down;
isolated one-off fatals are classified scanner-induced and stay off the RED
trigger — a fatal signature spanning ERROR_FATAL_FANOUT_ACCOUNTS or more
accounts is never one of them, see ERROR_FATAL_SCANNER;
ERROR_FATAL_SCANNER_EXCLUDE vetoes that classification for your own CLI
entrypoints, so a broken wp maintenance script still turns the light RED —
the veto only ever moves a fatal toward genuine, never away) —
shown in the subject line so the inbox list reads at a glance. It covers up to four
planes of server health:
backend-failed: N (top: <cause>)) so a
silently erroring firewall or API never hides inside the block counts.ERROR_DIGEST_ENABLE) — FATAL/ERROR from Apache,
PHP-FPM, per-site PHP, and MySQL over the same window, with known high-volume
noise filtered and the rest grouped by signature. Point it at logs directly, or
reuse an existing consolidated error log.ORIGIN_LOCK_DIGEST, default auto) — when the
window contains ORIGIN-LOCK: syslog hits, a dedicated plane shows the
direct-to-origin drop count, unique source IPs, the :80/:443 split, and
top sources tagged attacker/legit. auto shows it only when there are hits, so
it stays invisible for operators who don't run the lock; ORIGIN_LOCK_LOG
selects the syslog source.SWARM_ENABLE=true) — an informational plane summarizing
what the fleet gave and got: fleet IPs consumed from the shared feed (with a
stale-feed note when the feed is older than SWARM_MAX_AGE_DAYS), corroborated
pre-blocks taken this window, confirmed bans contributed back this window, and
the date of the last contribution. It reads only local swarm state at report
time (no hub call) and is purely informational — it never changes the report
grade or breaks the silent-when-quiet behavior, and it degrades to a note (not
silent zeros) on a box without jq.It stays silent on a genuinely quiet window. Delivery is pluggable —
sendmail (default), SendGrid (free tier 100/day), or Brevo (free tier
300/day) — so hosts whose IP isn't an authorized sender for the From domain can
still deliver via a free transactional-email account. The installer schedules
it via REPORT_CRON (default 0 4 — 4 AM) and REPORT_CRON_TZ (an IANA zone
like America/Los_Angeles; empty = server/UTC), baked into /etc/cron.d/swatter
with a CRON_TZ header so the send time doesn't drift across DST.
swatter report --test # force-send now, to verify delivery
swatter report 7d # ad-hoc 7-day digest
swatter report --print # print the digest to stdout, send nothing
All the knobs live in /etc/swatter/swatter.conf:
Set REPORT_EMAIL (recipient; empty disables the digest) and REPORT_FROM
(sender address). These two are all you normally set — REPORT_FROM_NAME
defaults to Swatter (<this host's FQDN>) and the subject is generated per
report, so you rarely touch either.
Pick REPORT_METHOD. sendmail needs nothing else. sendgrid and brevo
need curl + jq plus an API key — keep it in a root-only file and point
the config at it:
install -m 600 /dev/null /etc/swatter/sendgrid.key # or .../brevo.key
vi /etc/swatter/sendgrid.key # paste the API key, nothing else
Then in swatter.conf, either:
REPORT_METHOD="sendgrid" +
SENDGRID_KEY_FILE="/etc/swatter/sendgrid.key", orREPORT_METHOD="brevo" +
BREVO_KEY_FILE="/etc/swatter/brevo.key".Verify delivery with swatter report --test.
Optional white-label: the HTML report's header shows the Peace Harbor
Studios lockup by default. Put your own company logo there with
REPORT_LOGO_URL (https URL, shown 360px wide) and REPORT_LOGO_ALT.
The footer branding is part of the project and is not configurable.
SWATTER_MODE="enforce".REPEAT_N
(default 3) temp blocks within REPEAT_WINDOW_DAYS (default 7) — never from
a single window, so one anomalous burst can't blackhole a shared/CGNAT IP. See
Recidivism ladder below for the escalation details,
preview/rollback tooling, and the operator gotchas that go with it.ABUSEIPDB_REPORT_MIN_ACTION="temp", and an address still carrying
a legacy offenders.perm=1 that shared-egress-audit --fix has not cleared.
It is not an allowlist either: the address is still blocked, every time it
misbehaves.csf.allow,
the server's own IPs, RFC1918, your operator IPs (swatter allow <ip>),
monitoring services, and forward-confirmed good crawlers (Googlebot/Bingbot
verified by reverse + forward DNS, because PTR alone is forgeable).wp-login.php is not a
crime — your clients do it every day. The brute-force floor only trips on
repeated failed attempts (errors or POST floods), so a site owner logging in
and working in wp-admin can never be blocked for it.import-bans — so a malformed token parsed from a
hostile log line can't reach //the CF API. Catastrophic-but-valid
targets are refused too: a (whole-internet deny) or an unspecified address
(, ) is never blocked, even from an untrusted file.Requirements: a cPanel/Apache + CSF server (AlmaLinux/CentOS/RHEL), gawk,
flock. Optional: jq + curl (threat-intel & Cloudflare API), sqlite3
(falls back to a flat file otherwise).
git clone https://github.com/peaceharborco/swatter.git
cd swatter
# On the server itself (as root):
sudo ./install/install.sh local
# …or push from your workstation over SSH:
./install/install.sh remote root@your-server
Then:
swatter refresh-feeds # pull Cloudflare ranges + intel feeds
swatter test-config # sanity-check deps, paths, CF token
swatter scan --dry-run # see what it WOULD do
swatter top # review the worst offenders
Before you enforce: teach it who you are. Run in report mode for a week and
review what it would have blocked (swatter top, swatter why <ip>, the
nightly digest). In report mode top ranks by SCORE with OFFN/TEMP at 0
— nothing was enforced yet — so read the score and LAST column. Note that two
IPs with the same score look identical there whether one was seen once and the
other forty times; swatter why <ip> shows the full would-have-blocked history
and is what separates a customer's one-off from a chronic scanner. Any IP on that list
that's actually you, your staff, or a client belongs in the never-block set
before the first enforce run:
swatter allow 203.0.113.7 "office"
swatter allow 198.51.100.0/24 "client X static range"
An auto-blocker's worst outcome is banning the customer — a site owner fumbling a password and then working in wp-admin can look a lot like an attack. Swatter's scoring is built to tell those apart, but your operator IPs deserve a guarantee, not a probability.
When you trust it, set SWATTER_MODE="enforce" in /etc/swatter/swatter.conf.
The installer adds a cron entry that scans every 5 minutes and refreshes feeds
daily. After upgrading, run swatter refresh-feeds so the new feeds download.
To block proxied attackers at Cloudflare (not just CSF-direct ones), Swatter
needs a token per CF account (IP Access Rules — a different product from the WAF
Rulesets, so it never collides with your existing WAF automation). Where the
rule lands is set by CF_SCOPE:
zone (default) — blocks each attacker in the specific zone they hit,
using your domain→account map. Token needs only Firewall Services: Edit.
Smallest blast radius, but a scanner that rotates target vhosts is only
challenged on the first zone it touches: once Swatter's per-IP ledger marks the
IP handled, it stops creating rules, so the IP roams free across every other
zone on the account.account — blocks the attacker on every account at once, so the block
scope matches the per-IP ledger and a vhost-rotating scanner is shut out
everywhere. No target vhost is required, so it also covers raw-IP / no-Host
offenders. Token additionally needs Account Firewall Access Rules: Edit. This
is the recommended scope when Swatter is your main line of defense; a token
lacking account scope makes account blocks fail and retry each run (logged with
a scope hint) so the misconfig surfaces, rather than silently skipping.In the Cloudflare dashboard, create an API token per account: Zone →
Firewall Services → Edit over that account's zones (and, for
CF_SCOPE=account, also Account → Account Firewall Access Rules → Edit).
Build a root-only creds file (account<TAB>token, one line per account) and a
cf-domains.conf-style domain list, then deploy both from your workstation —
no secret is committed and only the minimal token lands on the server:
./install/swatter-deploy-cf-creds.sh \
--creds /path/to/cloudflare.creds \
--domains /path/to/cf-domains.conf \
--host root@your-server
This writes /etc/swatter/cloudflare.creds (0600) and
/etc/swatter/cf-domains.map (0644), then runs test-config. A dry-run will
then show cloudflare block <ip> in <domain> for proxied offenders. (Hosts
not behind Cloudflare can skip all of this entirely — the default
CF_MODE="auto" detects a bare box and runs as a plain CSF auto-blocker; set
CF_MODE="off" to make that explicit and skip detection.)
By default Swatter infers direct-vs-proxied from your logs and from live origin
sockets — a TCP peer on your web ports (DIRECT_WEB_PORTS, default 80 443)
that isn't a Cloudflare edge is provably direct, which catches an in-progress
flood that bypasses Cloudflare with a valid Host header. For log-based ground
truth too, add Cloudflare's ray ID to your Apache log format — present means the
request came through Cloudflare, absent means direct:
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" cfray=%{CF-Ray}i" swatter
(Optional — heuristics work without it.)
If you already run serious protection at the Cloudflare edge — say, a
geo-restricted managed challenge on wp-login.php and wp-admin — think about
what that does to the traffic Swatter sees at the origin: the hostile noise on
those paths never arrives. What's left hitting your login pages is
overwhelmingly legitimate (site owners and whoever passes your challenge), so
path-based suspicion at the origin inverts — it starts selecting for your
customers instead of against attackers.
Two ways to run in that setup:
CF_MODE="skip" — the cleanest split. Your edge rules own the proxied
realm; Swatter owns what the edge can never see: direct-to-origin traffic from
scanners that found your server's real IP. On that plane the bad-path table is
at full strength, because no legitimate user logs into WordPress via your raw
server IP. (Use skip, not off, here: the box is behind Cloudflare, so
classification must stay on or a proxied socket could be CSF-denied. skip
keeps classification and simply leaves the via-CF plane to your edge rules.)config/badpaths.conf
(e.g. wp-login.php HIGH → MEDIUM) if your edge protection is partial — for
instance a geo challenge that still admits same-country bots.Either way, the brute-force floor requires failure evidence (failed POSTs / error responses), so even the default tuning won't ban an owner for logging in.
| Command | Purpose |
|---|---|
swatter scan [--dry-run|--enforce] | ingest → score → decide → act |
swatter status | state, mode, allowlist health, counts |
swatter top [-n N] | worst tracked offenders |
swatter why <ip> | the evidence and history behind a block |
swatter allow <ip|cidr> [note] | add to the never-block set (both planes) |
swatter unblock <ip> [--perm-allow] | reverse a block on both planes |
swatter list [temp|perm|cf|allow] | current blocks / allowlist |
swatter report [WINDOW] [--test|--print] | email a digest grouped by offense + action (--print: stdout only) |
swatter refresh-feeds | update Cloudflare ranges + intel feeds (validates the range body; exits nonzero on failure so cron can alert) |
swatter test-config | validate config and dependencies |
swatter honeypot | print robots.txt + invisible-anchor snippet for the trap path |
swatter setup-ipset | create ipset sets + iptables DROP rules (idempotent; use with DIRECT_BACKEND=ipset) |
swatter metrics [--print] | write (or print) the Prometheus textfile |
swatter export-bans [file] | write the perm-ban list to a file (or stdout) |
swatter import-bans <file> | block each listed IP as perm (skips allowlisted / invalid) |
swatter escalate-preview [--window N] | who would escalate to a perm, or is one offense away, read-only from the ledger (sqlite only) |
swatter rollback-ladder --since <epoch|iso> [--dry-run] | bulk-undo permanent bans the recidivism ladder placed |
swatter pending [--drain-perms] [--dry-run] | inspect, or clear, the durable failed-block retry queue (sqlite only) |
swatter shared-egress-audit [--fix] [--force] | list (or unblock) permanent bans sitting on shared consumer-VPN egress (sqlite only) |
logs ─▶ ingest ─▶ score.awk ─▶ [past WATCH?] ─▶ threat-intel ─▶ classify ─┬▶ CSF (direct)
(domlogs, (weighted (per-IP (AbuseIPDB/ (direct └▶ Cloudflare WAF (proxied)
access_log) signals + evidence) Spamhaus/ vs CF)
decisive IPsum) │
floors) ▼
never-block check (last)
+ circuit breakers
│
▼
SQLite ledger + decisions.jsonl
Incremental log reads use a per-file byte cursor (exact across the busiest logs,
survives rotation). Everything is set -uo pipefail, single-instance via flock,
and parses with awk in one pass — it scales to high-volume hours without forking
per line.
MIT © Peace Harbor Studios. See LICENSE.
Contributions welcome — new firewall backends (ipset/nftables), an nginx log parser, and additional intel providers all slot into the existing adapter interfaces.
Swatter is developed with AI pair-programming and adversarial review baked into
the workflow: Claude (Anthropic) as the primary
development partner, with Grok (xAI) and
Cursor serving as independent adversarial reviewers on
plans, risky changes, and pre-release audits. Their reviews are credited as
Co-Authored-By trailers in the git history.
csfipset/00.0.0.0::import-bansswatter why <ip> shows exactly what triggered a block;
swatter unblock <ip> [--perm-allow] reverses it on both planes.evidence.backend_err), and unblock exits
nonzero with an INCOMPLETE message if any plane's removal failed, instead of
printing a false "unblocked" while a rule is still live. A block that hits a
transient backend error (a Cloudflare API blip, a csf hiccup) is queued and
durably retried on later scans — so a short-lived offender whose evidence
has already scrolled out of the log window still gets blocked, rather than
slipping through because the one attempt happened to fail.