Files
security-alert-center/backend/app/services/notify_dispatch.py
T
PapaTramp d47131cd9f feat: live update log in UI and suppress lifecycle Telegram during SAC update
Poll remote update_script.log while SSH update runs; skip lifecycle notify
when host agent_update_state is running (SAC v0.5.1).
2026-07-08 11:46:53 +10:00

197 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dispatch ingest notifications per global policy (severity → channels)."""
import logging
from sqlalchemy.orm import Session
from app.models import Event, Host, Problem
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
from app.services.notification_cooldown import should_notify_event, should_notify_problem
from app.services.notification_policy import get_effective_notification_policy
from app.services.event_type_visibility import event_type_notifications_enabled
from app.services.host_health import HEARTBEAT_TYPE
from app.services.notification_severity import severity_meets_minimum
logger = logging.getLogger(__name__)
LIFECYCLE_EVENT_TYPE = "agent.lifecycle"
DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"})
RDG_CONNECTION_TYPES = frozenset({
"rdg.connection.success",
"rdg.connection.disconnected",
"rdg.connection.failed",
})
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 _skip_notifications_for_hidden_event(event: Event, db: Session | None) -> bool:
if event_type_notifications_enabled(event.type, db):
return False
logger.info(
"notify skipped hidden event type=%s event_id=%s",
event.type,
event.event_id,
)
return True
def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None:
if policy.use_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)
if policy.use_mobile:
mobile_notify.notify_event(event, db=db, apply_policy_gate=False)
def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Session | None, policy) -> None:
if policy.use_telegram:
telegram_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
if policy.use_webhook:
webhook_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
if policy.use_email:
email_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
if policy.use_mobile:
mobile_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
def notify_event(event: Event, *, db: Session | None = None) -> None:
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
if event.type == HEARTBEAT_TYPE:
return
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db)
if not severity_meets_minimum(event.severity, policy.min_severity):
return
if not should_notify_event(event, db):
return
_dispatch_event_channels(event, db=db, policy=policy)
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
policy = get_effective_notification_policy(db)
if not severity_meets_minimum(problem.severity, policy.min_severity):
return
if not should_notify_problem(problem, db):
return
_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)
if policy.use_mobile:
mobile_notify.notify_event(event, db=db, apply_policy_gate=False)
def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
"""Оповещение по суточному отчёту (severity=info, вне порога policy).
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
"""
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db)
if not should_notify_event(event, db):
return
_dispatch_lifecycle_channels(event, db=db, policy=policy)
def _host_sac_update_running(event: Event, db: Session | None) -> bool:
"""Пока SAC выполняет agent-update на хосте — не слать lifecycle в Telegram."""
if db is None or not event.host_id:
return False
host = db.get(Host, event.host_id)
if host is None:
return False
if (host.agent_update_state or "").strip().lower() == "running":
logger.info(
"notify lifecycle skipped host update running host_id=%s event_id=%s",
host.id,
event.event_id,
)
return True
return False
def _lifecycle_suppress_notifications(event: Event, *, db: Session | None = None) -> bool:
"""Не слать TG при штатном SAC/cron update (lifecycle с trigger deploy_recycle)."""
if _host_sac_update_running(event, db):
return True
details = event.details if isinstance(event.details, dict) else {}
trigger = str(details.get("trigger") or "").strip().lower()
if trigger in ("deploy_recycle", "sac_update", "agent_update"):
logger.info(
"notify lifecycle skipped trigger=%s event_id=%s",
trigger,
event.event_id,
)
return True
return False
def notify_lifecycle(event: Event, *, db: Session | None = None) -> None:
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
if _lifecycle_suppress_notifications(event, db=db):
return
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db)
if not should_notify_event(event, db):
return
_dispatch_lifecycle_channels(event, db=db, policy=policy)
def notify_auth_login(event: Event, *, db: Session | None = None) -> None:
"""Успешный удалённый вход RDP/SSH — всегда в Telegram SAC (info вне min_severity).
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
"""
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db)
if not should_notify_event(event, db):
return
_dispatch_lifecycle_channels(event, db=db, policy=policy)
def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
"""RD Gateway 302/303 — ingest всегда; Telegram SAC вне min_severity (как auth login)."""
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db)
if not should_notify_event(event, db):
return
_dispatch_lifecycle_channels(event, db=db, policy=policy)
def schedule_notify_daily_report(event_db_id: int) -> None:
"""Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST)."""
from app.database import SessionLocal
db = SessionLocal()
try:
event = db.get(Event, event_db_id)
if event is None:
logger.warning("deferred daily report notify: event id=%s not found", event_db_id)
return
notify_daily_report(event, db=db)
except Exception:
logger.exception("deferred daily report notify failed event_db_id=%s", event_db_id)
finally:
db.close()