|
#!/usr/bin/env bash |
|
set -euo pipefail |
|
|
|
# Fetches the published crawler IP ranges of AI bots and renders them into an |
|
# nginx geo map ($ai_blocked_ip). Validates every prefix, installs atomically, |
|
# checks with `nginx -t`, rolls back on failure and only reloads on change. |
|
# |
|
# Usage: update-ai-blocklist.sh [location ...] |
|
# |
|
# Optional location arguments (e.g. "/observation" or "~ ^/api/reports") do not |
|
# change the generated blocklist — they print ready-to-paste nginx snippets |
|
# that apply the block to exactly those URLs in your server {} block. |
|
# |
|
# Environment overrides (useful for testing): |
|
# OUT=... target conf path (default /etc/nginx/conf.d/ai-blocklist.conf) |
|
# MIN_PREFIXES=... safety floor, refuse a suspiciously short list (default 20) |
|
# RELOAD=auto|yes|no nginx test + reload (default auto: only when nginx is in PATH) |
|
# GEO_PROXY=... space-separated CIDRs of trusted proxies; when set, nginx |
|
# takes the client IP from X-Forwarded-For for these senders |
|
|
|
OUT=${OUT:-/etc/nginx/conf.d/ai-blocklist.conf} |
|
MIN_PREFIXES=${MIN_PREFIXES:-20} |
|
RELOAD=${RELOAD:-auto} |
|
|
|
# ordered list -> deterministic output, also works with macOS bash 3.2 |
|
SOURCES=( |
|
'gptbot https://openai.com/gptbot.json' |
|
'oai-searchbot https://openai.com/searchbot.json' |
|
'chatgpt-user https://openai.com/chatgpt-user.json' |
|
'perplexitybot https://www.perplexity.ai/perplexitybot.json' |
|
'perplexity-user https://www.perplexity.ai/perplexity-user.json' |
|
# 'claudebot <anthropic-json-url>' # add once Anthropic ships a stable endpoint |
|
) |
|
|
|
IPV4_CIDR='^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$' |
|
IPV6_CIDR='^[0-9A-Fa-f:]+/([0-9]|[1-9][0-9]|1[01][0-9]|12[0-8])$' |
|
|
|
die() { |
|
printf 'ERROR: %s\n' "$*" >&2 |
|
exit 1 |
|
} |
|
|
|
if [[ ${1:-} == -h || ${1:-} == --help ]]; then |
|
sed -n '3,/^$/s/^# \{0,1\}//p' "$0" |
|
exit 0 |
|
fi |
|
|
|
PROTECTED_LOCATIONS=("$@") |
|
|
|
print_paste_snippets() { |
|
(( ${#PROTECTED_LOCATIONS[@]} > 0 )) || return 0 |
|
local location |
|
printf '\nPaste into every server {} block that should deny AI bots:\n\n' |
|
for location in "${PROTECTED_LOCATIONS[@]}"; do |
|
# $ai_blocked_ip is an nginx variable, not shell |
|
# shellcheck disable=SC2016 |
|
printf 'location %s {\n if ($ai_blocked_ip) {\n return 403;\n }\n\n # ... your existing proxy_pass/fastcgi/root config for this location\n}\n\n' "$location" |
|
done |
|
} |
|
|
|
for dependency in curl jq; do |
|
command -v "$dependency" >/dev/null || die "required tool '$dependency' is not installed" |
|
done |
|
|
|
workdir=$(mktemp -d) |
|
staged='' |
|
trap 'rm -rf "$workdir" ${staged:+"$staged"}' EXIT |
|
|
|
fetch_prefixes() { |
|
local name=$1 url=$2 json prefixes prefix |
|
json=$(curl -fsS --max-time 20 --retry 2 "$url") \ |
|
|| die "fetch failed for $name ($url) — previous list stays in place" |
|
prefixes=$(jq -r '.prefixes[]? | (.ipv4Prefix // .ipv6Prefix) | strings' <<<"$json") \ |
|
|| die "unparseable JSON from $name" |
|
[[ -n $prefixes ]] || die "no prefixes in response of $name (upstream format change?)" |
|
while IFS= read -r prefix; do |
|
[[ $prefix =~ $IPV4_CIDR || $prefix =~ $IPV6_CIDR ]] \ |
|
|| die "refusing invalid prefix '$prefix' from $name" |
|
printf '%s\n' "$prefix" |
|
done <<<"$prefixes" |
|
} |
|
|
|
body=$workdir/body |
|
for entry in "${SOURCES[@]}"; do |
|
read -r name url <<<"$entry" |
|
printf ' # %s\n' "$name" |
|
fetch_prefixes "$name" "$url" | sed 's/^/ /; s/$/ 1;/' |
|
done >"$body" |
|
|
|
# duplicate networks across vendors would trigger nginx geo warnings |
|
deduped=$workdir/deduped |
|
awk '/^[[:space:]]*#/ { print; next } !seen[$1]++' "$body" >"$deduped" |
|
|
|
count=$(grep -c ' 1;$' "$deduped" || true) |
|
(( count >= MIN_PREFIXES )) || die "only $count prefixes parsed (< $MIN_PREFIXES), aborting" |
|
|
|
new_conf=$workdir/new.conf |
|
{ |
|
printf '# managed by update-ai-blocklist.sh — do not edit\n' |
|
# $ai_blocked_ip is an nginx variable, not shell |
|
# shellcheck disable=SC2016 |
|
printf 'geo $ai_blocked_ip {\n default 0;\n' |
|
read -ra trusted_proxies <<<"${GEO_PROXY:-}" |
|
for proxy_cidr in ${trusted_proxies[@]+"${trusted_proxies[@]}"}; do |
|
printf ' proxy %s;\n' "$proxy_cidr" |
|
done |
|
cat "$deduped" |
|
printf '}\n' |
|
} >"$new_conf" |
|
|
|
if cmp -s "$new_conf" "$OUT" 2>/dev/null; then |
|
printf '%s unchanged (%d prefixes), nothing to do\n' "$OUT" "$count" |
|
print_paste_snippets |
|
exit 0 |
|
fi |
|
|
|
outdir=$(dirname "$OUT") |
|
[[ -d $outdir ]] || die "target directory $outdir does not exist" |
|
|
|
backup='' |
|
if [[ -f $OUT ]]; then |
|
backup=$workdir/previous.conf |
|
cp -p "$OUT" "$backup" |
|
fi |
|
|
|
staged=$(mktemp "$outdir/.ai-blocklist.XXXXXX") |
|
cp "$new_conf" "$staged" |
|
chmod 0644 "$staged" |
|
mv -f "$staged" "$OUT" |
|
staged='' |
|
|
|
if [[ $RELOAD == no ]] || { [[ $RELOAD == auto ]] && ! command -v nginx >/dev/null; }; then |
|
printf '%s updated: %d prefixes (nginx reload skipped)\n' "$OUT" "$count" |
|
print_paste_snippets |
|
exit 0 |
|
fi |
|
|
|
if ! nginx -t 2>"$workdir/nginx-test.out"; then |
|
cat "$workdir/nginx-test.out" >&2 |
|
cp "$new_conf" "${OUT}.rejected" |
|
if [[ -n $backup ]]; then |
|
mv -f "$backup" "$OUT" |
|
die "nginx -t rejected the new config; previous config restored, rejected version kept at ${OUT}.rejected" |
|
fi |
|
rm -f "$OUT" |
|
die "nginx -t rejected the new config; removed it (no previous version existed), rejected version kept at ${OUT}.rejected" |
|
fi |
|
|
|
if command -v systemctl >/dev/null; then |
|
systemctl reload nginx |
|
else |
|
nginx -s reload |
|
fi |
|
printf '%s updated: %d prefixes, nginx reloaded\n' "$OUT" "$count" |
|
print_paste_snippets |