← back to La Socrata Ingester

scripts/apply-ratelimit.sh

61 lines

#!/bin/bash
# Apply a Cloudflare rate-limit rule to protect buildingpermits.agentabrams.com /api/.
# Needs a WAF-scoped token (Zone > WAF > Edit, scoped to agentabrams.com).
# Non-destructive: reads the existing http_ratelimit entrypoint and only adds our
# rule if it isn't already present.
#
# Usage:
#   DRY_RUN=1 bash scripts/apply-ratelimit.sh        # preview rule+target, no token, no writes
#   CLOUDFLARE_WAF_TOKEN=xxxx bash scripts/apply-ratelimit.sh   # apply (GATED — Steve only)
#   (token may also be passed as $1)
set -euo pipefail
TOKEN="${CLOUDFLARE_WAF_TOKEN:-${1:-}}"
ZONE="0121a01d603b55dd23cb23d78987e16e"   # agentabrams.com
API="https://api.cloudflare.com/client/v4"
DESC="buildingpermits API rate limit"

# The rule: block an IP that exceeds 20 req/10s to /api/ on the public host,
# for 10s. (period must be 10 on this CF plan; 20/10s = 120/min, same effective
# ceiling as the original 120/60s.) cf.colo.id is required by the ratelimit
# schema; ip.src is the counter.
RULE='{"action":"block","description":"'"$DESC"'","expression":"(http.host eq \"buildingpermits.agentabrams.com\" and starts_with(http.request.uri.path, \"/api/\"))","ratelimit":{"characteristics":["ip.src","cf.colo.id"],"period":10,"requests_per_period":20,"mitigation_timeout":10}}'

# DRY_RUN — print exactly what WOULD be sent, touch nothing, need no token.
if [ "${DRY_RUN:-0}" = "1" ]; then
  echo "DRY RUN — no token used, no Cloudflare write."
  echo "Zone: agentabrams.com ($ZONE)"
  echo "Phase: http_ratelimit   Endpoint: $API/zones/$ZONE/rulesets/..."
  echo "Rule to add:"
  echo "$RULE" | node -e 'const d=JSON.parse(require("fs").readFileSync(0));console.log(JSON.stringify(d,null,2))'
  exit 0
fi

# No token yet? If we're on a real terminal, prompt for it (hidden). This avoids
# the shell trap of typing  CLOUDFLARE_WAF_TOKEN=<token>  where the angle brackets
# are read as a file redirect. Just run:  bash scripts/apply-ratelimit.sh
if [ -z "$TOKEN" ] && [ -t 0 ]; then
  read -rs -p "Paste Cloudflare WAF token (Zone>WAF>Edit, agentabrams.com), then Enter: " TOKEN
  echo
fi
[ -n "$TOKEN" ] || { echo "No token. Set CLOUDFLARE_WAF_TOKEN, pass it as \$1, or run DRY_RUN=1 to preview."; exit 1; }
auth=(-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json")

# 1. read the current http_ratelimit entrypoint (ruleset id + rules)
EP=$(curl -s "${auth[@]}" "$API/zones/$ZONE/rulesets/phases/http_ratelimit/entrypoint")
if echo "$EP" | grep -q '"success":false'; then
  echo "Read failed (token likely lacks WAF scope):"; echo "$EP" | head -c 200; exit 1
fi
RID=$(echo "$EP" | node -e 'const d=JSON.parse(require("fs").readFileSync(0));console.log(d.result?.id||"")')
if echo "$EP" | grep -q "$DESC"; then echo "Rule already present — nothing to do."; exit 0; fi

if [ -n "$RID" ]; then
  echo "Appending rule to existing ruleset $RID …"
  curl -s "${auth[@]}" -X POST "$API/zones/$ZONE/rulesets/$RID/rules" -d "$RULE" \
    | node -e 'const d=JSON.parse(require("fs").readFileSync(0));console.log(d.success?"✔ rate-limit rule added":"✖ "+JSON.stringify(d.errors).slice(0,200))'
else
  echo "No entrypoint yet — creating http_ratelimit ruleset with the rule …"
  curl -s "${auth[@]}" -X PUT "$API/zones/$ZONE/rulesets/phases/http_ratelimit/entrypoint" \
    -d '{"rules":['"$RULE"']}' \
    | node -e 'const d=JSON.parse(require("fs").readFileSync(0));console.log(d.success?"✔ rate-limit ruleset created":"✖ "+JSON.stringify(d.errors).slice(0,200))'
fi