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:
13
.env.example
Normal file
13
.env.example
Normal file
@@ -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
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -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"]
|
||||||
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)
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -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
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
requests>=2.31,<3
|
||||||
Reference in New Issue
Block a user