Files
crowdsec-bunny-bouncer/bouncer.py
kappa d1b870227e Add CrowdSec bouncer for Bunny CDN Shield
Syncs locally-detected CrowdSec ban decisions to Bunny CDN Shield
Access Lists. Excludes community blocklists (CAPI/lists) since Bunny
Shield has its own managed threat feeds.

- Polls CrowdSec LAPI for origin=crowdsec/cscli bans
- Updates Bunny Shield custom Access List via PATCH API
- Change detection via set comparison to skip unnecessary API calls
- Exponential backoff retry on API failures
- Graceful SIGTERM/SIGINT shutdown
- Docker healthcheck support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 19:08:56 +09:00

252 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""CrowdSec Bouncer for Bunny CDN — syncs ban decisions to Shield Access Lists."""
import logging
import os
import signal
import sys
import time
from pathlib import Path
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
CROWDSEC_LAPI_URL = os.environ["CROWDSEC_LAPI_URL"].rstrip("/")
CROWDSEC_LAPI_KEY = os.environ["CROWDSEC_LAPI_KEY"]
BUNNY_API_KEY = os.environ["BUNNY_API_KEY"]
BUNNY_SHIELD_ZONE_ID = os.environ["BUNNY_SHIELD_ZONE_ID"]
BUNNY_ACCESS_LIST_ID = os.environ["BUNNY_ACCESS_LIST_ID"]
SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "60"))
MAX_IPS = int(os.environ.get("MAX_IPS", "1000"))
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
BUNNY_API_BASE = "https://api.bunny.net"
HEALTHCHECK_FILE = Path("/tmp/bouncer-healthy")
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=LOG_LEVEL,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("bouncer")
# ---------------------------------------------------------------------------
# Graceful shutdown
# ---------------------------------------------------------------------------
_running = True
def _shutdown(signum, _frame):
global _running
log.info("Received signal %s, shutting down…", signal.Signals(signum).name)
_running = False
signal.signal(signal.SIGTERM, _shutdown)
signal.signal(signal.SIGINT, _shutdown)
# ---------------------------------------------------------------------------
# HTTP sessions with retry
# ---------------------------------------------------------------------------
def _make_session(headers: dict, retries: int = 3) -> requests.Session:
s = requests.Session()
s.headers.update(headers)
adapter = HTTPAdapter(
max_retries=Retry(
total=retries,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST", "PATCH"],
)
)
s.mount("http://", adapter)
s.mount("https://", adapter)
return s
crowdsec_session = _make_session({"X-Api-Key": CROWDSEC_LAPI_KEY})
bunny_session = _make_session(
{"AccessKey": BUNNY_API_KEY, "Content-Type": "application/json"}
)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
_last_pushed: set[str] = set()
# ---------------------------------------------------------------------------
# CrowdSec helpers
# ---------------------------------------------------------------------------
def fetch_banned_ips() -> list[str]:
"""Return locally-detected banned IPs from CrowdSec LAPI, capped at MAX_IPS."""
url = f"{CROWDSEC_LAPI_URL}/v1/decisions"
resp = crowdsec_session.get(url, params={"type": "ban"}, timeout=10)
resp.raise_for_status()
data = resp.json()
if data is None:
return []
# Only keep locally-detected decisions (exclude CAPI community blocklists)
seen: set[str] = set()
unique: list[str] = []
for d in data:
if d.get("origin") not in ("crowdsec", "cscli"):
continue
ip = d.get("value", "")
if ip and ip not in seen:
seen.add(ip)
unique.append(ip)
if len(unique) > MAX_IPS:
log.warning(
"CrowdSec has %d locally-detected banned IPs, truncating to %d",
len(unique),
MAX_IPS,
)
unique = unique[:MAX_IPS]
return unique
# ---------------------------------------------------------------------------
# Bunny CDN Shield Access Lists helpers
# ---------------------------------------------------------------------------
def _access_list_url() -> str:
return f"{BUNNY_API_BASE}/shield/shield-zone/{BUNNY_SHIELD_ZONE_ID}/access-lists/{BUNNY_ACCESS_LIST_ID}"
def sync_access_list(banned_ips: list[str]):
"""Update the Shield Access List with the current set of banned IPs."""
global _last_pushed
ip_set = set(banned_ips)
if ip_set == _last_pushed:
log.debug("No changes, skipping sync")
return
content = "\n".join(banned_ips) if banned_ips else ""
resp = bunny_session.patch(
_access_list_url(),
json={"content": content},
timeout=30,
)
if not resp.ok:
log.error("Bunny API %s: %s", resp.status_code, resp.text)
resp.raise_for_status()
_last_pushed = ip_set
log.info("Synced %d IPs to Shield Access List", len(banned_ips))
# ---------------------------------------------------------------------------
# Startup validation
# ---------------------------------------------------------------------------
def verify_connections():
"""Check connectivity to CrowdSec LAPI and Bunny Shield API at startup."""
log.info("Verifying CrowdSec LAPI at %s", CROWDSEC_LAPI_URL)
resp = crowdsec_session.get(
f"{CROWDSEC_LAPI_URL}/v1/decisions", params={"type": "ban"}, timeout=10
)
resp.raise_for_status()
log.info("CrowdSec LAPI: OK")
log.info("Verifying Bunny Shield zone %s", BUNNY_SHIELD_ZONE_ID)
resp = bunny_session.get(
f"{BUNNY_API_BASE}/shield/shield-zone/{BUNNY_SHIELD_ZONE_ID}/access-lists",
timeout=15,
)
resp.raise_for_status()
data = resp.json()
found = False
for cl in data.get("customLists", []):
if str(cl.get("listId")) == BUNNY_ACCESS_LIST_ID:
found = True
log.info(
"Access List '%s': enabled=%s, action=%s, entries=%s/%s",
cl.get("name"),
cl.get("isEnabled"),
cl.get("action"),
data.get("customEntryCount"),
data.get("customEntryLimit"),
)
break
if not found:
log.error("Access List ID %s not found in Shield zone", BUNNY_ACCESS_LIST_ID)
sys.exit(1)
log.info("Bunny Shield: OK")
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
def main():
log.info(
"Starting CrowdSec Bunny Bouncer — shield_zone=%s access_list=%s interval=%ds max_ips=%d",
BUNNY_SHIELD_ZONE_ID,
BUNNY_ACCESS_LIST_ID,
SYNC_INTERVAL,
MAX_IPS,
)
verify_connections()
HEALTHCHECK_FILE.touch()
while _running:
try:
banned = fetch_banned_ips()
log.debug("Fetched %d banned IPs", len(banned))
sync_access_list(banned)
except requests.RequestException:
log.exception("Sync cycle failed, will retry next interval")
HEALTHCHECK_FILE.unlink(missing_ok=True)
except Exception:
log.exception("Unexpected error in sync cycle")
HEALTHCHECK_FILE.unlink(missing_ok=True)
else:
HEALTHCHECK_FILE.touch()
for _ in range(SYNC_INTERVAL):
if not _running:
break
time.sleep(1)
HEALTHCHECK_FILE.unlink(missing_ok=True)
log.info("Bouncer stopped")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
log.info("Interrupted")
sys.exit(0)
except Exception:
log.exception("Fatal error")
sys.exit(1)