From 3d4ee65ec6f9f630a0f7e5f6446d2da12f88f20e Mon Sep 17 00:00:00 2001 From: Maxime Killinger Date: Fri, 28 Aug 2026 22:04:39 +0200 Subject: [PATCH] feat: add /alertmanager endpoint as Alertmanager -> Apprise bridge Reuses the same notification path as /crowdsec: formats the Alertmanager webhook payload (firing/resolved, severity, instance, summary/description) and forwards it to APPRISE_URL as {title, body}. Co-Authored-By: Claude Sonnet 5 --- README.md | 15 ++++++++++++ app.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/README.md b/README.md index 1b62f3d..0b73b30 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,21 @@ headers: Content-Type: application/json ``` +## Configuration Alertmanager + +Sert aussi de pont entre Alertmanager et Apprise (le `webhook_configs` d'Alertmanager +n'a pas de templating de payload, il envoie son format natif tel quel). + +Dans `alertmanager.yml` : + +```yaml +receivers: + - name: 'apprise-notifications' + webhook_configs: + - url: 'http://crowdsec-notify:5000/alertmanager' + send_resolved: true +``` + ## Auteur Maxime Killinger diff --git a/app.py b/app.py index 79163b3..c0fd9e4 100644 --- a/app.py +++ b/app.py @@ -111,6 +111,74 @@ def handle_crowdsec(): 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 + + def format_alert(alert): + labels = alert.get("labels", {}) + annotations = alert.get("annotations", {}) + name = labels.get("alertname", "alerte") + severity = labels.get("severity", "") + instance = labels.get("instance") or labels.get("container") or labels.get("job") or "" + + head = name + if severity: + head += f" [{severity}]" + if instance: + head += f" — {instance}" + + out = [head] + summary = annotations.get("summary") + description = annotations.get("description") + if summary: + out.append(f" {summary}") + if description and description != summary: + out.append(f" {description}") + return "\n".join(out) + + firing = [a for a in alerts if a.get("status") == "firing"] + resolved = [a for a in alerts if a.get("status") == "resolved"] + + lines = [] + if firing: + lines.append(f"🔥 {len(firing)} alerte(s) active(s)") + lines.extend(format_alert(a) for a in firing) + if resolved: + if lines: + lines.append("") + lines.append(f"✅ {len(resolved)} résolue(s)") + lines.extend(format_alert(a) for a in resolved) + + status = data.get("status", "firing") + group_name = data.get("groupLabels", {}).get("alertname", "") + title = ("🔥" if status == "firing" else "✅") + " Alertmanager" + if group_name: + title += f" — {group_name}" + + body = "\n".join(lines).strip() + + # 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