Files
crowdsec-notify/app.py
T
maxime.killinger 3d5f3c6299
🚀 Docker Build and Push / build-and-push (push) Successful in 4m46s
feat: align Alertmanager notifications with the CrowdSec feed format
Alertmanager notifications were hard to read: the alert name appeared
twice, the instance was repeated between the header and the summary, the
count was restated in the body, and details ran flat with no visual
structure.

They now follow the same grammar as the CrowdSec feed: a title carrying
the counts, then one block per alert made of an identity line
("<icon> <name> (<context>)") and details indented under a tree marker.
Severity moves into the icon (red/orange/blue, green once resolved)
instead of a "[critical]" suffix, which keeps the identity line short
enough to read at a glance on a phone.
2026-08-31 01:04:19 +02:00

221 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""
CrowdSec Notification Webhook
Groups alerts by IP and sends formatted notifications via Apprise
Author: Maxime Killinger
"""
import os
import logging
from flask import Flask, request, jsonify
import requests
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
import pycountry
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
APPRISE_URL = os.environ.get("APPRISE_URL")
def get_country_name(code):
if not code or code.lower() == 'unknown':
logger.warning(f"Country code is unknown or empty: {code}")
return "Unknown"
try:
# Standardize code to alpha_2
# Clean string just in case
clean_code = code.strip().upper()
if len(clean_code) != 2:
logger.warning(f"Invalid country code format: {code}")
return clean_code
country = pycountry.countries.get(alpha_2=clean_code)
if country:
return country.name
logger.warning(f"Country code not found in pycountry: {clean_code}")
return clean_code
except Exception as e:
logger.warning(f"Error resolving country code {code}: {e}")
return code
def format_scenario(scenario):
if not scenario:
return "unknown"
return scenario.replace("crowdsecurity/", "").replace("_", " ")
# One icon per alert, mirroring the per-IP icon used by the CrowdSec feed.
# Severity is carried by the icon instead of a "[critical]" suffix, which
# keeps the identity line short enough to read at a glance on a phone.
SEVERITY_ICONS = {
"critical": "🔴",
"warning": "🟠",
"info": "🔵",
}
def format_alert_block(alert, resolved=False):
"""Render one alert the way the CrowdSec feed renders one IP: an identity
line (icon, name, context in parentheses), then the details indented under
a tree marker. Returns the block as a single string."""
labels = alert.get("labels", {})
annotations = alert.get("annotations", {})
name = labels.get("alertname", "alerte")
severity = labels.get("severity", "")
# "name" is what cAdvisor-based rules use to carry the container name
context = (labels.get("instance") or labels.get("container")
or labels.get("name") or labels.get("job") or "")
icon = "✅" if resolved else SEVERITY_ICONS.get(severity, "🔥")
head = f"{icon} {name}"
if context:
head += f" ({context})"
summary = annotations.get("summary")
description = annotations.get("description")
details = [summary] if summary else []
# description often just restates summary; only keep it when it adds something
if description and description != summary:
details.append(description)
lines = [head] + [f" └ {d}" for d in details]
return "\n".join(lines)
@app.route('/crowdsec', methods=['POST'])
def handle_crowdsec():
try:
alerts = request.json
if not alerts:
logger.debug("Received empty alert payload")
return jsonify({"status": "no data"}), 200
# Group by IP
ip_groups = {}
for alert in alerts:
ip = alert.get("source", {}).get("ip", "unknown")
country = alert.get("source", {}).get("cn", "")
scenario = alert.get("scenario", "unknown")
if ip not in ip_groups:
ip_groups[ip] = {"country": country, "scenarios": set()}
ip_groups[ip]["scenarios"].add(format_scenario(scenario))
# Format message
num_ips = len(ip_groups)
lines = []
for ip, data in ip_groups.items():
country_name = get_country_name(data["country"])
whois_link = f"https://who.is/whois-ip/ip-address/{ip}"
lines.append(f"🚫 {ip} ({country_name})")
lines.append(f" 🔗 {whois_link}")
for scenario in sorted(data["scenarios"]):
lines.append(f" └ {scenario}")
lines.append("")
body = "\n".join(lines).strip()
title = f"🛡️ CrowdSec - {num_ips} IP{'s' if num_ips > 1 else ''} bannie{'s' if num_ips > 1 else ''}"
# Send to Apprise
response = requests.post(APPRISE_URL, json={"title": title, "body": body}, timeout=10)
if response.ok:
ips_list = ", ".join(ip_groups.keys())
logger.info(f"Notification sent: {num_ips} IP(s) banned [{ips_list}]")
else:
logger.error(f"Apprise error: {response.status_code} - {response.text}")
return jsonify({"status": "sent", "ips": num_ips}), 200
except Exception as e:
logger.exception(f"Error processing CrowdSec alert: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/alertmanager', methods=['POST'])
def handle_alertmanager():
try:
data = request.json or {}
alerts = data.get("alerts", [])
if not alerts:
logger.debug("Received empty Alertmanager payload")
return jsonify({"status": "no data"}), 200
firing = [a for a in alerts if a.get("status") == "firing"]
resolved = [a for a in alerts if a.get("status") == "resolved"]
# One block per alert, blank-line separated, like the CrowdSec feed
blocks = [format_alert_block(a) for a in firing]
blocks += [format_alert_block(a, resolved=True) for a in resolved]
# Title carries the counts, so the body never has to repeat them
counts = []
if firing:
plural = "s" if len(firing) > 1 else ""
counts.append(f"{len(firing)} alerte{plural} active{plural}")
if resolved:
counts.append(f"{len(resolved)} résolue{'s' if len(resolved) > 1 else ''}")
title = ("🔥" if firing else "✅") + f" Alertmanager - {', '.join(counts)}"
body = "\n\n".join(blocks)
# Send to Apprise
response = requests.post(APPRISE_URL, json={"title": title, "body": body}, timeout=10)
if response.ok:
logger.info(f"Alertmanager notification sent: {len(firing)} firing, {len(resolved)} resolved")
else:
logger.error(f"Apprise error: {response.status_code} - {response.text}")
return jsonify({"status": "error", "code": response.status_code}), 502
return jsonify({"status": "sent", "firing": len(firing), "resolved": len(resolved)}), 200
except Exception as e:
logger.exception(f"Error processing Alertmanager alert: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/health', methods=['GET'])
def health():
return jsonify({"status": "ok"}), 200
if __name__ == '__main__':
from waitress import serve
import sys
import signal
if not APPRISE_URL:
logger.critical("APPRISE_URL environment variable is not set")
sys.exit(1)
def handle_signal(signum, frame):
logger.info(f"Received signal {signum}. Shutting down gracefully...")
# Waitress does not have a public stop method when run this way easily,
# but sys.exit(0) from a signal handler usually works to terminate.
# However, waitress catches signals if passed to serve?
# Actually waitress handles signals by itself if running in main.
# But we want to LOG it.
sys.exit(0)
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
logger.info(f"Starting CrowdSec Notify - Apprise URL: {APPRISE_URL}")
serve(app, host='0.0.0.0', port=5000)