351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""External notifications — Discord, Telegram, SMTP, generic webhook (#168).
|
|
|
|
Configuration is persisted in ``data/notify_channels.json``; secrets live in
|
|
``data/notify_secrets.json`` (0600) or in ``OBSIGATE_NOTIFY_SECRET_<ID>``
|
|
environment variables — never in the public config file (same pattern as
|
|
``backend/webhooks.py``, BUG-026).
|
|
|
|
Supported channel types:
|
|
|
|
* ``discord`` — Discord webhook URL (``POST {"content": ...}``).
|
|
* ``telegram`` — Bot API (``POST https://api.telegram.org/bot<token>/sendMessage``).
|
|
* ``smtp`` — Email via stdlib ``smtplib`` (STARTTLS, auth login).
|
|
* ``webhook`` — generic JSON ``POST`` (SSRF-safe, same policy as #9).
|
|
|
|
Each channel declares ``triggers`` chosen among :data:`VALID_TRIGGERS`.
|
|
The scheduler (#170) broadcasts on ``schedule_failure``; file-event fan-out
|
|
stays on the historical ``backend/webhooks.py`` path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import smtplib
|
|
import threading
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from email.message import EmailMessage
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger("obsigate.notify")
|
|
|
|
DATA_DIR = Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
|
|
CHANNELS_FILE = DATA_DIR / "notify_channels.json"
|
|
SECRETS_FILE = DATA_DIR / "notify_secrets.json"
|
|
|
|
CHANNEL_TYPES = ("discord", "telegram", "smtp", "webhook")
|
|
VALID_TRIGGERS = ("manual", "schedule_failure", "schedule_success", "duplicate_found")
|
|
|
|
_lock = threading.RLock()
|
|
|
|
|
|
# ── Store helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _read_channels() -> list[dict[str, Any]]:
|
|
if not CHANNELS_FILE.exists():
|
|
return []
|
|
try:
|
|
data = json.loads(CHANNELS_FILE.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, list) else []
|
|
except (json.JSONDecodeError, OSError):
|
|
return []
|
|
|
|
|
|
def _write_channels(channels: list[dict[str, Any]]) -> None:
|
|
CHANNELS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = CHANNELS_FILE.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(channels, indent=2, default=str), encoding="utf-8")
|
|
tmp.replace(CHANNELS_FILE)
|
|
|
|
|
|
def _read_secrets() -> dict[str, str]:
|
|
if not SECRETS_FILE.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(SECRETS_FILE.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, dict) else {}
|
|
except (json.JSONDecodeError, OSError):
|
|
return {}
|
|
|
|
|
|
def _write_secrets(secrets: dict[str, str]) -> None:
|
|
SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = SECRETS_FILE.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(secrets, indent=2), encoding="utf-8")
|
|
tmp.replace(SECRETS_FILE)
|
|
try:
|
|
SECRETS_FILE.chmod(0o600)
|
|
except OSError:
|
|
pass # Windows: pas de permissions Unix
|
|
|
|
|
|
def _secret_key(channel_id: str) -> str:
|
|
return "OBSIGATE_NOTIFY_SECRET_" + channel_id.replace("-", "_").upper()
|
|
|
|
|
|
def _get_secret(channel_id: str) -> str | None:
|
|
"""Resolve a channel secret: env > dedicated store > legacy inline config."""
|
|
env_val = os.environ.get(_secret_key(channel_id))
|
|
if env_val:
|
|
return env_val
|
|
stored = _read_secrets().get(channel_id)
|
|
if stored:
|
|
return stored
|
|
for ch in _read_channels():
|
|
if ch.get("id") == channel_id:
|
|
cfg = ch.get("config", {})
|
|
for key in ("webhook_url", "bot_token", "password"):
|
|
if cfg.get(key):
|
|
return str(cfg[key])
|
|
return None
|
|
|
|
|
|
def _public_view(channel: dict[str, Any]) -> dict[str, Any]:
|
|
clean = {k: v for k, v in channel.items() if k != "config"}
|
|
cfg = dict(channel.get("config", {}))
|
|
for secret_field in ("webhook_url", "bot_token", "password"):
|
|
if cfg.get(secret_field):
|
|
cfg[secret_field] = "***"
|
|
clean["config"] = cfg
|
|
clean["has_secret"] = bool(_get_secret(channel["id"]))
|
|
return clean
|
|
|
|
|
|
# ── CRUD ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _validate_config(channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
"""Validate (sans secret) and normalize a channel config. Raises ValueError."""
|
|
config = dict(config or {})
|
|
if channel_type == "discord":
|
|
url = str(config.get("webhook_url") or config.get("url") or "").strip()
|
|
if not url.startswith(("https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/")):
|
|
# Laisse passer les URLs de test locales quand le mode privé est ouvert.
|
|
from backend.webhooks import validate_webhook_url
|
|
|
|
validate_webhook_url(url)
|
|
if "discord" not in url and not os.environ.get("OBSIGATE_WEBHOOK_ALLOW_PRIVATE"):
|
|
raise ValueError("URL Discord invalide (webhook discord.com attendu)")
|
|
config["webhook_url"] = url
|
|
elif channel_type == "telegram":
|
|
if not str(config.get("chat_id") or "").strip():
|
|
raise ValueError("chat_id Telegram requis")
|
|
config["chat_id"] = str(config["chat_id"]).strip()
|
|
if config.get("bot_token"):
|
|
config["bot_token"] = str(config["bot_token"]).strip()
|
|
elif channel_type == "smtp":
|
|
for field in ("host", "from_addr", "to_addr"):
|
|
if not str(config.get(field) or "").strip():
|
|
raise ValueError(f"Champ SMTP requis : {field}")
|
|
config["port"] = int(config.get("port") or 587)
|
|
config["use_tls"] = bool(config.get("use_tls", True))
|
|
config["username"] = str(config.get("username") or "").strip()
|
|
elif channel_type == "webhook":
|
|
from backend.webhooks import validate_webhook_url
|
|
|
|
url = str(config.get("url") or "").strip()
|
|
validate_webhook_url(url)
|
|
config["url"] = url
|
|
else:
|
|
raise ValueError(f"Type de canal inconnu : {channel_type}")
|
|
triggers = [t for t in (config.get("triggers") or ["manual"]) if t in VALID_TRIGGERS]
|
|
config["triggers"] = triggers or ["manual"]
|
|
return config
|
|
|
|
|
|
def list_channels() -> list[dict[str, Any]]:
|
|
"""Return public views of all notification channels."""
|
|
return [_public_view(ch) for ch in _read_channels()]
|
|
|
|
|
|
def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
"""Create a notification channel. Secrets are split into the secret store."""
|
|
if channel_type not in CHANNEL_TYPES:
|
|
raise ValueError(f"Type de canal inconnu : {channel_type}")
|
|
with _lock:
|
|
channels = _read_channels()
|
|
channel_id = str(uuid.uuid4())
|
|
normalized = _validate_config(channel_type, config)
|
|
secrets = _read_secrets()
|
|
for field in ("webhook_url", "bot_token", "password"):
|
|
if normalized.get(field) and len(str(normalized[field])) > 8:
|
|
secrets[channel_id] = str(normalized[field])
|
|
normalized[field] = "***" # placeholder : le secret vit dans le store dédié
|
|
_write_secrets(secrets)
|
|
channel = {
|
|
"id": channel_id,
|
|
"name": (name or channel_type).strip() or channel_type,
|
|
"type": channel_type,
|
|
"enabled": True,
|
|
"config": normalized,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"last_sent_at": None,
|
|
"last_error": None,
|
|
}
|
|
channels.append(channel)
|
|
_write_channels(channels)
|
|
logger.info(f"Created notify channel '{name}' ({channel_type})")
|
|
return _public_view(channel)
|
|
|
|
|
|
def update_channel(channel_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""Update a channel (name/enabled/config). Returns None when unknown."""
|
|
with _lock:
|
|
channels = _read_channels()
|
|
for channel in channels:
|
|
if channel.get("id") != channel_id:
|
|
continue
|
|
if updates.get("name"):
|
|
channel["name"] = str(updates["name"])
|
|
if "enabled" in updates:
|
|
channel["enabled"] = bool(updates["enabled"])
|
|
if "config" in updates and isinstance(updates["config"], dict):
|
|
merged = {**channel.get("config", {}), **updates["config"]}
|
|
normalized = _validate_config(channel["type"], merged)
|
|
secrets = _read_secrets()
|
|
for field in ("webhook_url", "bot_token", "password"):
|
|
if updates["config"].get(field):
|
|
secrets[channel_id] = str(updates["config"][field])
|
|
normalized[field] = "***"
|
|
_write_secrets(secrets)
|
|
channel["config"] = normalized
|
|
_write_channels(channels)
|
|
return _public_view(channel)
|
|
return None
|
|
|
|
|
|
def delete_channel(channel_id: str) -> bool:
|
|
"""Delete a channel and its secret. Returns False when unknown."""
|
|
with _lock:
|
|
channels = _read_channels()
|
|
remaining = [c for c in channels if c.get("id") != channel_id]
|
|
if len(remaining) == len(channels):
|
|
return False
|
|
_write_channels(remaining)
|
|
secrets = _read_secrets()
|
|
if secrets.pop(channel_id, None) is not None:
|
|
_write_secrets(secrets)
|
|
return True
|
|
|
|
|
|
# ── Dispatch ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _send_discord(webhook_url: str, title: str, message: str) -> None:
|
|
import httpx
|
|
|
|
content = f"**{title}**\n{message}"[:2000]
|
|
resp = httpx.post(webhook_url, json={"content": content}, timeout=10.0)
|
|
resp.raise_for_status()
|
|
|
|
|
|
def _send_telegram(bot_token: str, chat_id: str, title: str, message: str) -> None:
|
|
import httpx
|
|
|
|
from backend.webhooks import is_safe_target
|
|
|
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
if not is_safe_target(url):
|
|
raise RuntimeError("Cible Telegram bloquée par la politique SSRF")
|
|
text = f"*{title}*\n{message}"[:4000]
|
|
resp = httpx.post(
|
|
url,
|
|
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
|
|
timeout=10.0,
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
def _send_smtp(config: dict[str, Any], password: str | None, title: str, message: str) -> None:
|
|
msg = EmailMessage()
|
|
msg["Subject"] = f"[ObsiGate] {title}"
|
|
msg["From"] = config["from_addr"]
|
|
msg["To"] = config["to_addr"]
|
|
msg.set_content(message)
|
|
with smtplib.SMTP(str(config["host"]), int(config.get("port", 587)), timeout=10) as client:
|
|
if config.get("use_tls", True):
|
|
client.starttls()
|
|
if config.get("username") and password:
|
|
client.login(str(config["username"]), password)
|
|
client.send_message(msg)
|
|
|
|
|
|
def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
|
|
import httpx
|
|
|
|
from backend.webhooks import is_safe_target
|
|
|
|
if not is_safe_target(url):
|
|
raise RuntimeError("Cible webhook bloquée par la politique SSRF")
|
|
resp = httpx.post(
|
|
url,
|
|
json={
|
|
"event": trigger,
|
|
"title": title,
|
|
"message": message,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"source": "obsigate-notify",
|
|
},
|
|
timeout=10.0,
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
def send_via_channel(channel: dict[str, Any], title: str, message: str, trigger: str = "manual") -> None:
|
|
"""Send a notification through one raw channel record. Raises on failure."""
|
|
channel_type = channel.get("type")
|
|
cfg = dict(channel.get("config", {}))
|
|
secret = _get_secret(channel["id"])
|
|
if channel_type == "discord":
|
|
url = secret or cfg.get("webhook_url") or ""
|
|
if not url or url == "***":
|
|
raise RuntimeError("URL webhook Discord manquante")
|
|
_send_discord(url, title, message)
|
|
elif channel_type == "telegram":
|
|
token = secret or cfg.get("bot_token") or os.environ.get("OBSIGATE_TELEGRAM_BOT_TOKEN") or ""
|
|
if not token or token == "***":
|
|
raise RuntimeError("Token bot Telegram manquant")
|
|
_send_telegram(token, str(cfg.get("chat_id", "")), title, message)
|
|
elif channel_type == "smtp":
|
|
_send_smtp(cfg, secret, title, message)
|
|
elif channel_type == "webhook":
|
|
url = str(cfg.get("url") or "").strip()
|
|
if not url:
|
|
raise RuntimeError("URL webhook manquante")
|
|
_send_webhook(url, title, message, trigger)
|
|
else:
|
|
raise RuntimeError(f"Type de canal inconnu : {channel_type}")
|
|
|
|
|
|
def broadcast(trigger: str, title: str, message: str) -> list[dict[str, Any]]:
|
|
"""Send to every enabled channel subscribed to *trigger*. Never raises."""
|
|
results: list[dict[str, Any]] = []
|
|
for channel in _read_channels():
|
|
if not channel.get("enabled", True):
|
|
continue
|
|
if trigger not in channel.get("config", {}).get("triggers", ["manual"]):
|
|
continue
|
|
try:
|
|
send_via_channel(channel, title, message, trigger)
|
|
results.append({"channel_id": channel["id"], "ok": True})
|
|
_mark_sent(channel["id"], None)
|
|
except Exception as e:
|
|
logger.warning(f"Notify channel '{channel.get('name')}' failed: {e}")
|
|
results.append({"channel_id": channel["id"], "ok": False, "error": str(e)})
|
|
_mark_sent(channel["id"], str(e))
|
|
return results
|
|
|
|
|
|
def _mark_sent(channel_id: str, error: str | None) -> None:
|
|
with _lock:
|
|
channels = _read_channels()
|
|
for channel in channels:
|
|
if channel.get("id") == channel_id:
|
|
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
|
|
channel["last_error"] = error
|
|
_write_channels(channels)
|