feat: always notify agent.lifecycle via SAC with telegram_via routing
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,7 +17,13 @@ from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
|||||||
from app.services.ingest import ingest_event
|
from app.services.ingest import ingest_event
|
||||||
from app.services.problems import maybe_create_problem
|
from app.services.problems import maybe_create_problem
|
||||||
from app.services.schema_validate import validate_event_payload
|
from app.services.schema_validate import validate_event_payload
|
||||||
from app.services.notify_dispatch import notify_daily_report, notify_event, notify_problem
|
from app.services.notify_dispatch import (
|
||||||
|
LIFECYCLE_EVENT_TYPE,
|
||||||
|
notify_daily_report,
|
||||||
|
notify_event,
|
||||||
|
notify_lifecycle,
|
||||||
|
notify_problem,
|
||||||
|
)
|
||||||
|
|
||||||
DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
||||||
|
|
||||||
@@ -68,6 +74,8 @@ def post_event(
|
|||||||
problem, problem_created = maybe_create_problem(db, event)
|
problem, problem_created = maybe_create_problem(db, event)
|
||||||
if event.type in DAILY_REPORT_EVENT_TYPES:
|
if event.type in DAILY_REPORT_EVENT_TYPES:
|
||||||
notify_daily_report(event, db=db)
|
notify_daily_report(event, db=db)
|
||||||
|
elif event.type == LIFECYCLE_EVENT_TYPE:
|
||||||
|
notify_lifecycle(event, db=db)
|
||||||
else:
|
else:
|
||||||
notify_event(event, db=db)
|
notify_event(event, db=db)
|
||||||
if problem is not None and problem_created:
|
if problem is not None and problem_created:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ COOLDOWN_KIND_EVENT = "event"
|
|||||||
COOLDOWN_KIND_PROBLEM = "problem"
|
COOLDOWN_KIND_PROBLEM = "problem"
|
||||||
|
|
||||||
# Не душим служебные проверки ingest
|
# Не душим служебные проверки ingest
|
||||||
_EVENT_COOLDOWN_EXEMPT_TYPES = frozenset({"agent.test"})
|
_EVENT_COOLDOWN_EXEMPT_TYPES = frozenset({"agent.test", "agent.lifecycle"})
|
||||||
|
|
||||||
|
|
||||||
def _as_utc(dt: datetime) -> datetime:
|
def _as_utc(dt: datetime) -> datetime:
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ from app.services.notification_severity import severity_meets_minimum
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LIFECYCLE_EVENT_TYPE = "agent.lifecycle"
|
||||||
|
|
||||||
|
|
||||||
|
def _event_telegram_via_agent(event: Event) -> bool:
|
||||||
|
details = event.details if isinstance(event.details, dict) else {}
|
||||||
|
via = str(details.get("telegram_via") or "").strip().lower()
|
||||||
|
return via == "agent"
|
||||||
|
|
||||||
|
|
||||||
def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None:
|
def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None:
|
||||||
if policy.use_telegram:
|
if policy.use_telegram:
|
||||||
@@ -49,9 +57,27 @@ def notify_problem(problem: Problem, event: Event | None = None, *, db: Session
|
|||||||
_dispatch_problem_channels(problem, event, db=db, policy=policy)
|
_dispatch_problem_channels(problem, event, db=db, policy=policy)
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_lifecycle_channels(event: Event, *, db: Session | None, policy) -> None:
|
||||||
|
skip_telegram = _event_telegram_via_agent(event)
|
||||||
|
if policy.use_telegram and not skip_telegram:
|
||||||
|
telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||||
|
if policy.use_webhook:
|
||||||
|
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||||
|
if policy.use_email:
|
||||||
|
email_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||||
|
|
||||||
|
|
||||||
def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
|
def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
|
||||||
"""Оповещение по суточному отчёту (severity=info, вне порога policy)."""
|
"""Оповещение по суточному отчёту (severity=info, вне порога policy)."""
|
||||||
policy = get_effective_notification_policy(db)
|
policy = get_effective_notification_policy(db)
|
||||||
if not should_notify_event(event, db):
|
if not should_notify_event(event, db):
|
||||||
return
|
return
|
||||||
_dispatch_event_channels(event, db=db, policy=policy)
|
_dispatch_event_channels(event, db=db, policy=policy)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_lifecycle(event: Event, *, db: Session | None = None) -> None:
|
||||||
|
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
|
||||||
|
policy = get_effective_notification_policy(db)
|
||||||
|
if not should_notify_event(event, db):
|
||||||
|
return
|
||||||
|
_dispatch_lifecycle_channels(event, db=db, policy=policy)
|
||||||
|
|||||||
@@ -277,7 +277,52 @@ def format_generic_event_html(event: Event) -> str:
|
|||||||
return msg.rstrip()
|
return msg.rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
_LIFECYCLE_HEADERS: dict[str, str] = {
|
||||||
|
"started": "✅ Агент запущен",
|
||||||
|
"stopped": "⚠️ Агент остановлен",
|
||||||
|
"settings_reloaded": "🔄 Настройки агента перечитаны",
|
||||||
|
}
|
||||||
|
|
||||||
|
_LIFECYCLE_TRIGGER_LABELS: dict[str, str] = {
|
||||||
|
"boot": "загрузка ОС / задача планировщика",
|
||||||
|
"deploy_recycle": "обновление скрипта (recycle)",
|
||||||
|
"settings_reload": "graceful restart (settings)",
|
||||||
|
"manual_recycle": "ручной recycle",
|
||||||
|
"shutdown": "остановка процесса",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_lifecycle_html(event: Event) -> str:
|
||||||
|
details = _details_dict(event)
|
||||||
|
lifecycle = _detail(details, "lifecycle", default="started").strip().lower()
|
||||||
|
trigger = _detail(details, "trigger", default="").strip().lower()
|
||||||
|
header = _LIFECYCLE_HEADERS.get(lifecycle, f"📋 Lifecycle: {html_escape(lifecycle)}")
|
||||||
|
|
||||||
|
msg = f"<b>{header}</b>\n"
|
||||||
|
msg += _line("🏢", "Хост", host_label(event.host))
|
||||||
|
product = ""
|
||||||
|
version = ""
|
||||||
|
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||||
|
source = payload.get("source")
|
||||||
|
if isinstance(source, dict):
|
||||||
|
product = str(source.get("product") or "").strip()
|
||||||
|
version = str(source.get("product_version") or "").strip()
|
||||||
|
if product or version:
|
||||||
|
label = " ".join(x for x in (product, version) if x).strip()
|
||||||
|
if label:
|
||||||
|
msg += _line("📦", "Версия", html_escape(label))
|
||||||
|
if trigger and trigger != "-":
|
||||||
|
trigger_human = _LIFECYCLE_TRIGGER_LABELS.get(trigger, trigger)
|
||||||
|
msg += _line("🔁", "Причина", html_escape(trigger_human))
|
||||||
|
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||||
|
if event.summary:
|
||||||
|
msg += f"\n{html_escape(event.summary)}"
|
||||||
|
return msg.rstrip()
|
||||||
|
|
||||||
|
|
||||||
def _format_event_body_html(event: Event) -> str:
|
def _format_event_body_html(event: Event) -> str:
|
||||||
|
if event.type == "agent.lifecycle":
|
||||||
|
return format_lifecycle_html(event)
|
||||||
if event.type in ("report.daily.ssh", "report.daily.rdp"):
|
if event.type in ("report.daily.ssh", "report.daily.rdp"):
|
||||||
details = _details_dict(event)
|
details = _details_dict(event)
|
||||||
platform = "windows" if event.type == "report.daily.rdp" else "ssh"
|
platform = "windows" if event.type == "report.daily.rdp" else "ssh"
|
||||||
|
|||||||
@@ -87,3 +87,59 @@ def test_notify_event_skipped_by_cooldown():
|
|||||||
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
|
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
|
||||||
notify_dispatch.notify_event(event)
|
notify_dispatch.notify_event(event)
|
||||||
mock_tg.notify_event.assert_not_called()
|
mock_tg.notify_event.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_lifecycle_bypasses_min_severity():
|
||||||
|
event = Event(
|
||||||
|
event_id="00000000-0000-4000-8000-000000000501",
|
||||||
|
host_id=1,
|
||||||
|
occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc),
|
||||||
|
category="agent",
|
||||||
|
type="agent.lifecycle",
|
||||||
|
severity="info",
|
||||||
|
title="started",
|
||||||
|
summary="agent started",
|
||||||
|
details={"lifecycle": "started", "telegram_via": "sac"},
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
policy = NotificationPolicyConfig(
|
||||||
|
min_severity="warning",
|
||||||
|
use_telegram=True,
|
||||||
|
use_webhook=False,
|
||||||
|
use_email=False,
|
||||||
|
source="db",
|
||||||
|
)
|
||||||
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
|
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
|
||||||
|
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
|
||||||
|
notify_dispatch.notify_lifecycle(event)
|
||||||
|
mock_tg.notify_event.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_lifecycle_skips_telegram_when_via_agent():
|
||||||
|
event = Event(
|
||||||
|
event_id="00000000-0000-4000-8000-000000000502",
|
||||||
|
host_id=1,
|
||||||
|
occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc),
|
||||||
|
category="agent",
|
||||||
|
type="agent.lifecycle",
|
||||||
|
severity="info",
|
||||||
|
title="started",
|
||||||
|
summary="agent started",
|
||||||
|
details={"lifecycle": "started", "telegram_via": "agent"},
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
policy = NotificationPolicyConfig(
|
||||||
|
min_severity="warning",
|
||||||
|
use_telegram=True,
|
||||||
|
use_webhook=True,
|
||||||
|
use_email=False,
|
||||||
|
source="db",
|
||||||
|
)
|
||||||
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
|
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
|
||||||
|
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
|
||||||
|
with patch.object(notify_dispatch, "webhook_notify") as mock_wh:
|
||||||
|
notify_dispatch.notify_lifecycle(event)
|
||||||
|
mock_tg.notify_event.assert_not_called()
|
||||||
|
mock_wh.notify_event.assert_called_once()
|
||||||
|
|||||||
@@ -87,6 +87,29 @@ def test_event_telegram_includes_sac_source_for_sac_daily_report():
|
|||||||
assert "📡 Оповещение: SAC (Security Alert Center)" in text
|
assert "📡 Оповещение: SAC (Security Alert Center)" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifecycle_started_template():
|
||||||
|
host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows")
|
||||||
|
event = Event(
|
||||||
|
event_id="00000000-0000-4000-8000-000000000505",
|
||||||
|
host_id=1,
|
||||||
|
host=host,
|
||||||
|
occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
|
||||||
|
category="agent",
|
||||||
|
type="agent.lifecycle",
|
||||||
|
severity="info",
|
||||||
|
title="started",
|
||||||
|
summary="Мониторинг запущен",
|
||||||
|
details={"lifecycle": "started", "trigger": "boot", "telegram_via": "sac"},
|
||||||
|
payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.30-SAC"}},
|
||||||
|
)
|
||||||
|
text = format_event_telegram_html(event)
|
||||||
|
assert "Агент запущен" in text
|
||||||
|
assert "K6A-DC3" in text
|
||||||
|
assert "1.2.30-SAC" in text
|
||||||
|
assert "загрузка ОС" in text
|
||||||
|
assert "📡 Оповещение: агент" in text
|
||||||
|
|
||||||
|
|
||||||
def test_rdp_shadow_control_template():
|
def test_rdp_shadow_control_template():
|
||||||
host = Host(hostname="RDS01", display_name="RDS Farm", ipv4="10.0.0.5", os_family="windows")
|
host = Host(hostname="RDS01", display_name="RDS Farm", ipv4="10.0.0.5", os_family="windows")
|
||||||
event = Event(
|
event = Event(
|
||||||
|
|||||||
Reference in New Issue
Block a user