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>
This commit is contained in:
251
bouncer.py
Normal file
251
bouncer.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user