commit d1b870227e2d6f63ed29f48ef9387e00c4ce707b Author: kappa Date: Thu Feb 12 19:08:56 2026 +0900 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3dcbaa1 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# CrowdSec LAPI connection +CROWDSEC_LAPI_URL=http://crowdsec:8080 +CROWDSEC_LAPI_KEY=your_bouncer_api_key_here + +# Bunny CDN Shield API +BUNNY_API_KEY=your_bunny_api_key_here +BUNNY_SHIELD_ZONE_ID=12345 +BUNNY_ACCESS_LIST_ID=12345 + +# Optional settings +SYNC_INTERVAL=60 +MAX_IPS=1000 +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cff5543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2f9cf55 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +RUN groupadd -r bouncer && useradd -r -g bouncer -s /sbin/nologin bouncer + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY bouncer.py . + +USER bouncer + +ENTRYPOINT ["python", "-u", "bouncer.py"] diff --git a/bouncer.py b/bouncer.py new file mode 100644 index 0000000..49cd257 --- /dev/null +++ b/bouncer.py @@ -0,0 +1,251 @@ +#!/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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7bff8c3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + bunny-bouncer: + build: . + container_name: crowdsec-bunny-bouncer + restart: unless-stopped + env_file: .env + network_mode: host + healthcheck: + test: ["CMD", "python", "-c", "import os, sys; sys.exit(0 if os.path.exists('/tmp/bouncer-healthy') else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d2a412 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests>=2.31,<3