A public IP blacklist is much more useful when it is automatically refreshed, auditable, and accompanied by a trend graph and a geographic breakdown. This tutorial shows how to publish a rolling 24-hour CrowdSec ban feed as plain text, draw its history with RRDtool, and display a world map using Google GeoChart with a green-to-yellow-to-red color scale.
1. Architecture and prerequisites
Keep the security engine and the public website logically separate. A scheduled job on the CrowdSec host extracts locally generated bans, filters private and trusted networks, builds static files and transfers them to the web server. The web server publishes blacklist.txt, blacklist-count.txt, blacklist-countries.csv and blacklist-history.png. No web request should invoke cscli or access CrowdSec’s local API directly.
sudo apt-get install -y python3 rrdtool openssh-client
sudo mkdir -p /var/lib/crowdsec-public /var/lib/crowdsec-public/out
sudo chmod 750 /var/lib/crowdsec-public
In this example a GeoIP lookup is performed with a local database (GeoLite2-Country.mmdb) by a separate enrichment step. Configure your own licensed GeoIP source before publishing the country statistics. Do not infer a country from the first octet of an IP address.
2. Export the last 24 hours of bans
A rolling 24-hour list is not equivalent to all currently active decisions: a 30-day ban may be active without having been created today. The following program selects IPv4 addresses from CrowdSec decisions whose created_at is within the last 24 hours when the API supplies that field. Verify your installed CrowdSec version’s JSON format before using this in production.
sudo cscli decisions list -a -o json > /var/lib/crowdsec-public/decisions.json
For portability, the next exporter uses the decision’s available timestamp, refuses to silently interpret missing dates, and removes RFC1918 addresses. It produces an alphabetical-by-IP list without any customer addresses or application logs.
cat > /var/lib/crowdsec-public/export.py <<'PY'
import json, ipaddress, datetime, pathlib
base = pathlib.Path('/var/lib/crowdsec-public')
decisions = json.loads((base / 'decisions.json').read_text())
if isinstance(decisions, dict):
decisions = decisions.get('decisions', [])
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=24)
ips = set()
for item in decisions:
if item.get('type') != 'ban' or item.get('scope', '').lower() != 'ip':
continue
created = item.get('created_at')
if not created:
continue # never pretend that active decisions are all new
try:
when = datetime.datetime.fromisoformat(created.replace('Z', '+00:00'))
ip = ipaddress.ip_address(item['value'])
except (KeyError, ValueError, TypeError):
continue
if when < cutoff or not isinstance(ip, ipaddress.IPv4Address):
continue
if not ip.is_global:
continue
ips.add(str(ip))
out = base / 'out'
out.mkdir(exist_ok=True)
(out / 'blacklist.txt').write_text(''.join(ip + '\n' for ip in sorted(ips, key=ipaddress.ip_address)))
(out / 'blacklist-count.txt').write_text(str(len(ips)) + '\n')
print('Exported:', len(ips))
PY
python3 /var/lib/crowdsec-public/export.py
Important: some CrowdSec versions and output formats omit creation timestamps. If this script exports zero entries despite known new bans, adapt its input to the local alert/decision API or structured event log instead of removing the date check. Do not use a simple grep of all active decisions to advertise a true 24-hour window. Build explicit allowlists for your infrastructure, monitoring services and verified crawlers before publishing; a security decision is not proof that an IP is malicious.
3. Create the history database with RRDtool
An RRD stores samples in fixed time slots and automatically consolidates older points. For a job running every 10 minutes, create a database with a 600-second step and a 30-minute heartbeat:
cd /var/lib/crowdsec-public
rrdtool create blacklist.rrd \
--step 600 \
DS:blocked:GAUGE:1800:0:U \
RRA:AVERAGE:0.5:1:288 \
RRA:MAX:0.5:1:288 \
RRA:AVERAGE:0.5:6:720
GAUGE is appropriate because the source is an instantaneous count, not an ever-increasing counter. The first archive stores two days of 10-minute points; the final archive stores longer history at hourly resolution. Create the RRD only once.
To add a measurement:
cd /var/lib/crowdsec-public
count=$(cat out/blacklist-count.txt)
rrdtool update blacklist.rrd "N:$count"
Use a locking mechanism if two runs could overlap: RRDtool rejects attempts to insert two samples at the same timestamp.
4. Render a 24-hour graph as a PNG
cd /var/lib/crowdsec-public
rrdtool graph out/blacklist-history.png \
--start end-1d --end now \
--width 880 --height 250 \
--title 'CrowdSec: IPs detected in the last 24 hours' \
--vertical-label 'Unique IPs' \
--color BACK#FFFFFF --color CANVAS#F7F7F7 \
DEF:blocked=blacklist.rrd:blocked:AVERAGE \
AREA:blocked#F28C28:'24-hour list size' \
LINE1:blocked#B45309 \
VDEF:peak=blocked,MAXIMUM \
VDEF:mean=blocked,AVERAGE \
GPRINT:peak:'Peak\\: %6.0lf' \
GPRINT:mean:'Average\\: %6.0lf'
Publish the PNG as a static file and refresh it with each successful data update. To avoid browser caching, reference it with a version parameter such as blacklist-history.png?v=20261113, derived from the actual latest modification time.
5. Generate country totals for a geographic heat map
The geo-enrichment step should produce a file containing one row per public IP, with a two-letter ISO country code, for example 203.0.113.10,ZZ (documentation address and placeholder country shown here). Exclude unknown and non-public addresses from the map. If a country cannot be resolved, keep its IP in the download but omit it from country aggregation.
Assuming ip-country.csv already contains IP,CC records from your GeoIP process:
cd /var/lib/crowdsec-public
python3 - <<'PY'
import csv, collections, pathlib
base = pathlib.Path('.')
counts = collections.Counter()
with (base / 'ip-country.csv').open(newline='') as stream:
for row in csv.reader(stream):
if len(row) < 2: continue
cc = row[1].strip().upper()
if len(cc) == 2 and cc.isalpha() and cc not in ('ZZ',):
counts[cc] += 1
with (base / 'out/blacklist-countries.csv').open('w', newline='') as stream:
writer = csv.writer(stream)
writer.writerow(['Country', 'Hits'])
writer.writerows(sorted(counts.items()))
PY
6. Build the world map with Google GeoChart
GeoChart shades each country according to its recorded count. The green → yellow → red scale means low → medium → high numbers of listed IP addresses. This is not a map of absolute threat severity: larger populations and more active networks can naturally generate more observations.
<div id="crowdsec-map" style="width:100%;min-height:500px"></div>
<script src="https://www.gstatic.com/charts/loader.js"></script>
<script>
google.charts.load('current', {packages:['geochart']});
google.charts.setOnLoadCallback(async function drawMap() {
const response = await fetch('/blacklist-countries.csv?t=' + Date.now(),
{cache:'no-store'});
if (!response.ok) throw new Error('Country CSV unavailable');
const csv = await response.text();
const rows = [['Country', 'Hits']];
csv.trim().split(/\r?\n/).slice(1).forEach(line => {
const [country, hits] = line.split(',');
const number = Number(hits);
if (/^[A-Z]{2}$/.test(country) && Number.isFinite(number))
rows.push([country, number]);
});
if (rows.length === 1) return;
const data = google.visualization.arrayToDataTable(rows);
const chart = new google.visualization.GeoChart(
document.getElementById('crowdsec-map')
);
chart.draw(data, {
region: 'world',
colorAxis: {colors: ['#26a269', '#f9c74f', '#e63946']},
backgroundColor: '#ffffff',
datalessRegionColor: '#eeeeee',
defaultColor: '#eeeeee',
legend: {textStyle: {fontSize: 12}}
});
});
</script>
The legend is generated automatically from the numeric range. For reproducible comparisons across days, explicitly configure colorAxis.minValue and colorAxis.maxValue based on a documented scale rather than relying on daily autoscaling.
7. Publish safely with cron and SCP
Copy files to a restricted account on the website. Prefer SSH key authentication with a dedicated deployment user, and upload into a staging directory before performing an atomic rename on the same filesystem. Avoid embedding passwords in scripts, command-line arguments or Git repositories.
# Example: replace deploy.example with your authorized target
scp /var/lib/crowdsec-public/out/blacklist* \
[email protected]:/home/publisher/staging/
Run the export, CSV aggregation, RRD update, chart rendering and upload from a wrapper with flock, so overlapping cron jobs cannot publish inconsistent snapshots. Example cron schedule:
*/10 * * * * root flock -n /run/lock/crowdsec-public.lock /usr/local/sbin/publish-crowdsec-list.sh >> /var/log/crowdsec-public.log 2>&1
The wrapper should check that each source file exists and each command succeeded before publication; it should also preserve the previous successful snapshot when any stage fails. Publish an update timestamp and monitor staleness. Validate downloads with curl -I and compare the public count to the generated blacklist-count.txt.
8. Operational and privacy cautions
- Never publish passwords, private addresses, raw logs, email addresses or customer hostnames.
- Check the licensing terms for any third-party IP intelligence or GeoIP database.
- Remove verified search-engine and AI-crawler networks from the public feed according to a maintained allowlist, but do not assume that User-Agent alone proves crawler identity.
- If the feed is used automatically by downstream firewalls, clearly document freshness, false-positive risks, retention and removal procedures.
- Google GeoChart loads a third-party JavaScript library: describe this in the website’s privacy notice or use a self-hosted mapping alternative if required.
Result: a static, easily cached blacklist download, a rolling RRD-based activity graph and a color-coded global map that updates with every successful scheduled publication—without exposing the CrowdSec API to the internet.