From 366a9c83f7e3d26f99d120b497f925bcb6584252 Mon Sep 17 00:00:00 2001 From: PTah Date: Sun, 31 May 2026 12:25:48 +1000 Subject: [PATCH] feat: always notify agent.lifecycle via SAC with telegram_via routing Co-authored-by: Cursor --- backend/app/api/v1/events.py | 10 +++- backend/app/services/notification_cooldown.py | 2 +- backend/app/services/notify_dispatch.py | 26 +++++++++ backend/app/services/telegram_templates.py | 45 +++++++++++++++ backend/tests/test_notify_dispatch.py | 56 +++++++++++++++++++ backend/tests/test_telegram_templates.py | 23 ++++++++ 6 files changed, 160 insertions(+), 2 deletions(-) diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 66b7a30..b331978 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -17,7 +17,13 @@ from app.schemas.list_models import EventDetail, EventListResponse, EventSummary from app.services.ingest import ingest_event from app.services.problems import maybe_create_problem 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"}) @@ -68,6 +74,8 @@ def post_event( problem, problem_created = maybe_create_problem(db, event) if event.type in DAILY_REPORT_EVENT_TYPES: notify_daily_report(event, db=db) + elif event.type == LIFECYCLE_EVENT_TYPE: + notify_lifecycle(event, db=db) else: notify_event(event, db=db) if problem is not None and problem_created: diff --git a/backend/app/services/notification_cooldown.py b/backend/app/services/notification_cooldown.py index b0f2f43..0e69450 100644 --- a/backend/app/services/notification_cooldown.py +++ b/backend/app/services/notification_cooldown.py @@ -18,7 +18,7 @@ COOLDOWN_KIND_EVENT = "event" COOLDOWN_KIND_PROBLEM = "problem" # Не душим служебные проверки ingest -_EVENT_COOLDOWN_EXEMPT_TYPES = frozenset({"agent.test"}) +_EVENT_COOLDOWN_EXEMPT_TYPES = frozenset({"agent.test", "agent.lifecycle"}) def _as_utc(dt: datetime) -> datetime: diff --git a/backend/app/services/notify_dispatch.py b/backend/app/services/notify_dispatch.py index 6f7c703..817f235 100644 --- a/backend/app/services/notify_dispatch.py +++ b/backend/app/services/notify_dispatch.py @@ -12,6 +12,14 @@ from app.services.notification_severity import severity_meets_minimum 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: 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) +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: """Оповещение по суточному отчёту (severity=info, вне порога policy).""" policy = get_effective_notification_policy(db) if not should_notify_event(event, db): return _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) diff --git a/backend/app/services/telegram_templates.py b/backend/app/services/telegram_templates.py index 92604a6..f67fbc0 100644 --- a/backend/app/services/telegram_templates.py +++ b/backend/app/services/telegram_templates.py @@ -277,7 +277,52 @@ def format_generic_event_html(event: Event) -> str: 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"{header}\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: + if event.type == "agent.lifecycle": + return format_lifecycle_html(event) if event.type in ("report.daily.ssh", "report.daily.rdp"): details = _details_dict(event) platform = "windows" if event.type == "report.daily.rdp" else "ssh" diff --git a/backend/tests/test_notify_dispatch.py b/backend/tests/test_notify_dispatch.py index 9f8cff7..f0bc571 100644 --- a/backend/tests/test_notify_dispatch.py +++ b/backend/tests/test_notify_dispatch.py @@ -87,3 +87,59 @@ def test_notify_event_skipped_by_cooldown(): with patch.object(notify_dispatch, "telegram_notify") as mock_tg: notify_dispatch.notify_event(event) 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() diff --git a/backend/tests/test_telegram_templates.py b/backend/tests/test_telegram_templates.py index 1bfbb31..dcd7584 100644 --- a/backend/tests/test_telegram_templates.py +++ b/backend/tests/test_telegram_templates.py @@ -87,6 +87,29 @@ def test_event_telegram_includes_sac_source_for_sac_daily_report(): 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(): host = Host(hostname="RDS01", display_name="RDS Farm", ipv4="10.0.0.5", os_family="windows") event = Event(