diff --git a/backend/alembic/versions/006_notification_smtp.py b/backend/alembic/versions/006_notification_smtp.py new file mode 100644 index 0000000..5ebecf8 --- /dev/null +++ b/backend/alembic/versions/006_notification_smtp.py @@ -0,0 +1,43 @@ +"""notification_channels: SMTP/email columns + +Revision ID: 006 +Revises: 005 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "006" +down_revision: Union[str, None] = "005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("notification_channels", sa.Column("smtp_host", sa.Text(), nullable=True)) + op.add_column("notification_channels", sa.Column("smtp_port", sa.Integer(), nullable=True)) + op.add_column("notification_channels", sa.Column("smtp_user", sa.String(length=128), nullable=True)) + op.add_column("notification_channels", sa.Column("smtp_password", sa.Text(), nullable=True)) + op.add_column("notification_channels", sa.Column("mail_from", sa.String(length=256), nullable=True)) + op.add_column("notification_channels", sa.Column("mail_to", sa.Text(), nullable=True)) + op.add_column( + "notification_channels", + sa.Column("smtp_starttls", sa.Boolean(), nullable=False, server_default=sa.text("true")), + ) + op.add_column( + "notification_channels", + sa.Column("smtp_ssl", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + + +def downgrade() -> None: + op.drop_column("notification_channels", "smtp_ssl") + op.drop_column("notification_channels", "smtp_starttls") + op.drop_column("notification_channels", "mail_to") + op.drop_column("notification_channels", "mail_from") + op.drop_column("notification_channels", "smtp_password") + op.drop_column("notification_channels", "smtp_user") + op.drop_column("notification_channels", "smtp_port") + op.drop_column("notification_channels", "smtp_host") diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 83cd5bd..20d2093 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -1,190 +1,283 @@ -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from app.auth.jwt_auth import get_current_user -from app.database import get_db -from app.services.notification_settings import ( - VALID_SEVERITIES, - TelegramConfig, - WebhookConfig, - get_effective_telegram_config, - get_effective_webhook_config, - upsert_telegram_channel, - upsert_webhook_channel, -) -from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message -from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message - -router = APIRouter(prefix="/settings", tags=["settings"]) - - -def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None: - text = value.strip() - if not text: - return None - if len(text) <= visible_tail: - return "*" * len(text) - return f"{'*' * 8}…{text[-visible_tail:]}" - - -def _mask_url(value: str) -> str | None: - text = value.strip() - if not text: - return None - if len(text) <= 20: - return f"{'*' * 6}…{text[-4:]}" - return f"{text[:16]}…{text[-6:]}" - - -class TelegramSettingsResponse(BaseModel): - enabled: bool - configured: bool - chat_id_hint: str | None = None - bot_token_hint: str | None = None - min_severity: str - source: str = Field(description="env или db") - - -class WebhookSettingsResponse(BaseModel): - enabled: bool - configured: bool - url_hint: str | None = None - secret_header: str | None = None - secret_hint: str | None = None - min_severity: str - source: str = Field(description="env или db") - - -class NotificationSettingsResponse(BaseModel): - telegram: TelegramSettingsResponse - webhook: WebhookSettingsResponse - - -class TelegramSettingsUpdate(BaseModel): - enabled: bool - min_severity: str - bot_token: str | None = None - chat_id: str | None = None - - -class WebhookSettingsUpdate(BaseModel): - enabled: bool - min_severity: str - url: str | None = None - secret_header: str | None = None - secret: str | None = None - - -class ChannelTestResponse(BaseModel): - ok: bool = True - message: str - - -def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse: - return TelegramSettingsResponse( - enabled=cfg.enabled, - configured=cfg.configured, - chat_id_hint=_mask_secret(cfg.chat_id), - bot_token_hint=_mask_secret(cfg.bot_token), - min_severity=cfg.min_severity, - source=cfg.source, - ) - - -def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse: - return WebhookSettingsResponse( - enabled=cfg.enabled, - configured=cfg.configured, - url_hint=_mask_url(cfg.url), - secret_header=cfg.secret_header or None, - secret_hint=_mask_secret(cfg.secret), - min_severity=cfg.min_severity, - source=cfg.source, - ) - - -@router.get("/notifications", response_model=NotificationSettingsResponse) -def get_notification_settings( - db: Session = Depends(get_db), - _user: str = Depends(get_current_user), -) -> NotificationSettingsResponse: - return NotificationSettingsResponse( - telegram=_telegram_to_response(get_effective_telegram_config(db)), - webhook=_webhook_to_response(get_effective_webhook_config(db)), - ) - - -@router.put("/notifications/telegram", response_model=TelegramSettingsResponse) -def update_telegram_settings( - body: TelegramSettingsUpdate, - db: Session = Depends(get_db), - _user: str = Depends(get_current_user), -) -> TelegramSettingsResponse: - if body.min_severity not in VALID_SEVERITIES: - raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") - try: - cfg = upsert_telegram_channel( - db, - enabled=body.enabled, - min_severity=body.min_severity, - bot_token=body.bot_token, - chat_id=body.chat_id, - ) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - return _telegram_to_response(cfg) - - -@router.put("/notifications/webhook", response_model=WebhookSettingsResponse) -def update_webhook_settings( - body: WebhookSettingsUpdate, - db: Session = Depends(get_db), - _user: str = Depends(get_current_user), -) -> WebhookSettingsResponse: - if body.min_severity not in VALID_SEVERITIES: - raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") - try: - cfg = upsert_webhook_channel( - db, - enabled=body.enabled, - min_severity=body.min_severity, - url=body.url, - secret_header=body.secret_header, - secret=body.secret, - ) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - return _webhook_to_response(cfg) - - -@router.post("/notifications/telegram/test", response_model=ChannelTestResponse) -def test_telegram_settings( - db: Session = Depends(get_db), - _user: str = Depends(get_current_user), -) -> ChannelTestResponse: - cfg = get_effective_telegram_config(db) - try: - send_telegram_test_message(config=cfg) - except TelegramNotConfiguredError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except TelegramSendError as exc: - code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502 - raise HTTPException(status_code=code, detail=str(exc)) from exc - return ChannelTestResponse(message="Тестовое сообщение отправлено в Telegram") - - -@router.post("/notifications/webhook/test", response_model=ChannelTestResponse) -def test_webhook_settings( - db: Session = Depends(get_db), - _user: str = Depends(get_current_user), -) -> ChannelTestResponse: - cfg = get_effective_webhook_config(db) - try: - send_webhook_test_message(config=cfg) - except WebhookNotConfiguredError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except WebhookSendError as exc: - code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502 - raise HTTPException(status_code=code, detail=str(exc)) from exc - return ChannelTestResponse(message="Тестовый POST отправлен на webhook URL") +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.auth.jwt_auth import get_current_user +from app.database import get_db +from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message +from app.services.notification_settings import ( + VALID_SEVERITIES, + EmailConfig, + TelegramConfig, + WebhookConfig, + get_effective_email_config, + get_effective_telegram_config, + get_effective_webhook_config, + upsert_email_channel, + upsert_telegram_channel, + upsert_webhook_channel, +) +from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message +from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None: + text = value.strip() + if not text: + return None + if len(text) <= visible_tail: + return "*" * len(text) + return f"{'*' * 8}…{text[-visible_tail:]}" + + +def _mask_url(value: str) -> str | None: + text = value.strip() + if not text: + return None + if len(text) <= 20: + return f"{'*' * 6}…{text[-4:]}" + return f"{text[:16]}…{text[-6:]}" + + +class TelegramSettingsResponse(BaseModel): + enabled: bool + configured: bool + chat_id_hint: str | None = None + bot_token_hint: str | None = None + min_severity: str + source: str = Field(description="env или db") + + +class WebhookSettingsResponse(BaseModel): + enabled: bool + configured: bool + url_hint: str | None = None + secret_header: str | None = None + secret_hint: str | None = None + min_severity: str + source: str = Field(description="env или db") + + +class EmailSettingsResponse(BaseModel): + enabled: bool + configured: bool + smtp_host_hint: str | None = None + smtp_port: int + smtp_user: str | None = None + smtp_password_hint: str | None = None + mail_from: str | None = None + mail_to_hint: str | None = None + smtp_starttls: bool + smtp_ssl: bool + min_severity: str + source: str = Field(description="env или db") + + +class NotificationSettingsResponse(BaseModel): + telegram: TelegramSettingsResponse + webhook: WebhookSettingsResponse + email: EmailSettingsResponse + + +class TelegramSettingsUpdate(BaseModel): + enabled: bool + min_severity: str + bot_token: str | None = None + chat_id: str | None = None + + +class WebhookSettingsUpdate(BaseModel): + enabled: bool + min_severity: str + url: str | None = None + secret_header: str | None = None + secret: str | None = None + + +class EmailSettingsUpdate(BaseModel): + enabled: bool + min_severity: str + smtp_host: str | None = None + smtp_port: int | None = None + smtp_user: str | None = None + smtp_password: str | None = None + mail_from: str | None = None + mail_to: str | None = None + smtp_starttls: bool | None = None + smtp_ssl: bool | None = None + + +class ChannelTestResponse(BaseModel): + ok: bool = True + message: str + + +def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse: + return TelegramSettingsResponse( + enabled=cfg.enabled, + configured=cfg.configured, + chat_id_hint=_mask_secret(cfg.chat_id), + bot_token_hint=_mask_secret(cfg.bot_token), + min_severity=cfg.min_severity, + source=cfg.source, + ) + + +def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse: + return WebhookSettingsResponse( + enabled=cfg.enabled, + configured=cfg.configured, + url_hint=_mask_url(cfg.url), + secret_header=cfg.secret_header or None, + secret_hint=_mask_secret(cfg.secret), + min_severity=cfg.min_severity, + source=cfg.source, + ) + + +def _email_to_response(cfg: EmailConfig) -> EmailSettingsResponse: + return EmailSettingsResponse( + enabled=cfg.enabled, + configured=cfg.configured, + smtp_host_hint=_mask_url(cfg.smtp_host) if cfg.smtp_host else None, + smtp_port=cfg.smtp_port, + smtp_user=cfg.smtp_user or None, + smtp_password_hint=_mask_secret(cfg.smtp_password), + mail_from=cfg.mail_from or None, + mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None, + smtp_starttls=cfg.smtp_starttls, + smtp_ssl=cfg.smtp_ssl, + min_severity=cfg.min_severity, + source=cfg.source, + ) + + +@router.get("/notifications", response_model=NotificationSettingsResponse) +def get_notification_settings( + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> NotificationSettingsResponse: + return NotificationSettingsResponse( + telegram=_telegram_to_response(get_effective_telegram_config(db)), + webhook=_webhook_to_response(get_effective_webhook_config(db)), + email=_email_to_response(get_effective_email_config(db)), + ) + + +@router.put("/notifications/telegram", response_model=TelegramSettingsResponse) +def update_telegram_settings( + body: TelegramSettingsUpdate, + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> TelegramSettingsResponse: + if body.min_severity not in VALID_SEVERITIES: + raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") + try: + cfg = upsert_telegram_channel( + db, + enabled=body.enabled, + min_severity=body.min_severity, + bot_token=body.bot_token, + chat_id=body.chat_id, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return _telegram_to_response(cfg) + + +@router.put("/notifications/webhook", response_model=WebhookSettingsResponse) +def update_webhook_settings( + body: WebhookSettingsUpdate, + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> WebhookSettingsResponse: + if body.min_severity not in VALID_SEVERITIES: + raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") + try: + cfg = upsert_webhook_channel( + db, + enabled=body.enabled, + min_severity=body.min_severity, + url=body.url, + secret_header=body.secret_header, + secret=body.secret, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return _webhook_to_response(cfg) + + +@router.put("/notifications/email", response_model=EmailSettingsResponse) +def update_email_settings( + body: EmailSettingsUpdate, + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> EmailSettingsResponse: + if body.min_severity not in VALID_SEVERITIES: + raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") + try: + cfg = upsert_email_channel( + db, + enabled=body.enabled, + min_severity=body.min_severity, + smtp_host=body.smtp_host, + smtp_port=body.smtp_port, + smtp_user=body.smtp_user, + smtp_password=body.smtp_password, + mail_from=body.mail_from, + mail_to=body.mail_to, + smtp_starttls=body.smtp_starttls, + smtp_ssl=body.smtp_ssl, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return _email_to_response(cfg) + + +@router.post("/notifications/telegram/test", response_model=ChannelTestResponse) +def test_telegram_settings( + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> ChannelTestResponse: + cfg = get_effective_telegram_config(db) + try: + send_telegram_test_message(config=cfg) + except TelegramNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except TelegramSendError as exc: + code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502 + raise HTTPException(status_code=code, detail=str(exc)) from exc + return ChannelTestResponse(message="Тестовое сообщение отправлено в Telegram") + + +@router.post("/notifications/webhook/test", response_model=ChannelTestResponse) +def test_webhook_settings( + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> ChannelTestResponse: + cfg = get_effective_webhook_config(db) + try: + send_webhook_test_message(config=cfg) + except WebhookNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except WebhookSendError as exc: + code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502 + raise HTTPException(status_code=code, detail=str(exc)) from exc + return ChannelTestResponse(message="Тестовый POST отправлен на webhook URL") + + +@router.post("/notifications/email/test", response_model=ChannelTestResponse) +def test_email_settings( + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> ChannelTestResponse: + cfg = get_effective_email_config(db) + try: + send_email_test_message(config=cfg) + except EmailNotConfiguredError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except EmailSendError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP") diff --git a/backend/app/config.py b/backend/app/config.py index 4a90b40..eae295f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -56,6 +56,17 @@ class Settings(BaseSettings): webhook_secret: str = "" webhook_min_severity: str = "high" + smtp_enabled: bool = False + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "" + smtp_to: str = "" + smtp_starttls: bool = True + smtp_ssl: bool = False + smtp_min_severity: str = "high" + # Порог «живости» агента sac_heartbeat_stale_minutes: int = 780 diff --git a/backend/app/models/notification_channel.py b/backend/app/models/notification_channel.py index 6b29efb..e14ecb3 100644 --- a/backend/app/models/notification_channel.py +++ b/backend/app/models/notification_channel.py @@ -17,6 +17,14 @@ class NotificationChannel(Base): webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True) webhook_secret_header: Mapped[str | None] = mapped_column(String(64), nullable=True) webhook_secret: Mapped[str | None] = mapped_column(Text, nullable=True) + smtp_host: Mapped[str | None] = mapped_column(Text, nullable=True) + smtp_port: Mapped[int | None] = mapped_column(nullable=True) + smtp_user: Mapped[str | None] = mapped_column(String(128), nullable=True) + smtp_password: Mapped[str | None] = mapped_column(Text, nullable=True) + mail_from: Mapped[str | None] = mapped_column(String(256), nullable=True) + mail_to: Mapped[str | None] = mapped_column(Text, nullable=True) + smtp_starttls: Mapped[bool] = mapped_column(default=True) + smtp_ssl: Mapped[bool] = mapped_column(default=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) diff --git a/backend/app/services/email_notify.py b/backend/app/services/email_notify.py new file mode 100644 index 0000000..2465fec --- /dev/null +++ b/backend/app/services/email_notify.py @@ -0,0 +1,135 @@ +"""SMTP email notifications from SAC.""" + +from __future__ import annotations + +import logging +import re +import smtplib +from email.mime.text import MIMEText + +from sqlalchemy.orm import Session + +from app.models import Event, Problem +from app.services.notification_settings import EmailConfig, get_effective_email_config +from app.services.telegram_notify import SEVERITY_ORDER + +logger = logging.getLogger(__name__) + + +class EmailNotConfiguredError(Exception): + """Email disabled or missing SMTP settings.""" + + +class EmailSendError(Exception): + pass + + +def _severity_value(value: str) -> int: + return SEVERITY_ORDER.get(value, 0) + + +def _should_notify_severity(severity: str, *, config: EmailConfig) -> bool: + return _severity_value(severity) >= _severity_value(config.min_severity) + + +def parse_mail_recipients(mail_to: str) -> list[str]: + return [part.strip() for part in re.split(r"[,;]", mail_to) if part.strip()] + + +def send_email_message( + subject: str, + body: str, + *, + config: EmailConfig | None = None, + force: bool = False, +) -> None: + cfg = config or get_effective_email_config() + if not force and not cfg.active: + return + if not cfg.configured: + if force: + raise EmailNotConfiguredError("SMTP: не задан host, from или to") + return + + recipients = parse_mail_recipients(cfg.mail_to) + if not recipients: + if force: + raise EmailNotConfiguredError("SMTP: список получателей пуст") + return + + msg = MIMEText(body, "plain", "utf-8") + msg["Subject"] = subject + msg["From"] = cfg.mail_from + msg["To"] = ", ".join(recipients) + + try: + if cfg.smtp_ssl: + server: smtplib.SMTP = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=12) + else: + server = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=12) + with server: + server.ehlo() + if cfg.smtp_starttls and not cfg.smtp_ssl: + server.starttls() + server.ehlo() + if cfg.smtp_user: + server.login(cfg.smtp_user, cfg.smtp_password) + server.sendmail(cfg.mail_from, recipients, msg.as_string()) + except Exception as exc: + logger.exception("smtp send failed") + raise EmailSendError(str(exc)) from exc + + +def send_email_test_message(*, config: EmailConfig | None = None) -> None: + cfg = config or get_effective_email_config() + if not cfg.enabled: + raise EmailNotConfiguredError("Email отключён (enabled=false)") + if not cfg.configured: + raise EmailNotConfiguredError("Не задан SMTP host, from или to") + send_email_message( + "SAC: тестовое письмо", + "Тестовое сообщение Security Alert Center.\nКанал SMTP для оповещений работает.", + config=cfg, + force=True, + ) + + +def notify_event(event: Event, *, db: Session | None = None) -> None: + cfg = get_effective_email_config(db) + if not cfg.active or not _should_notify_severity(event.severity, config=cfg): + return + host = event.host.hostname if event.host else "unknown" + body = ( + f"SAC событие\n" + f"Host: {host}\n" + f"Severity: {event.severity}\n" + f"Type: {event.type}\n" + f"Title: {event.title}\n" + f"Summary: {event.summary}" + ) + try: + send_email_message(f"SAC: {event.type} ({event.severity})", body, config=cfg) + except EmailSendError: + logger.exception("notify_event email failed event_id=%s", event.event_id) + + +def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: + cfg = get_effective_email_config(db) + if not cfg.active or not _should_notify_severity(problem.severity, config=cfg): + return + host = problem.host.hostname if problem.host else "unknown" + related = "" + if event is not None: + related = f"\nEvent: {event.type} ({event.severity})" + body = ( + f"SAC Problem opened\n" + f"Host: {host}\n" + f"Severity: {problem.severity}\n" + f"Rule: {problem.rule_id or '-'}\n" + f"Title: {problem.title}\n" + f"Summary: {problem.summary}{related}" + ) + try: + send_email_message(f"SAC Problem: {problem.title}", body, config=cfg) + except EmailSendError: + logger.exception("notify_problem email failed problem_id=%s", problem.id) diff --git a/backend/app/services/notification_settings.py b/backend/app/services/notification_settings.py index 973b89a..d7bd502 100644 --- a/backend/app/services/notification_settings.py +++ b/backend/app/services/notification_settings.py @@ -13,6 +13,7 @@ from app.models.notification_channel import NotificationChannel VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"}) CHANNEL_TELEGRAM = "telegram" CHANNEL_WEBHOOK = "webhook" +CHANNEL_EMAIL = "email" @dataclass(frozen=True) @@ -50,6 +51,29 @@ class WebhookConfig: return self.enabled and self.configured +@dataclass(frozen=True) +class EmailConfig: + enabled: bool + smtp_host: str + smtp_port: int + smtp_user: str + smtp_password: str + mail_from: str + mail_to: str + smtp_starttls: bool + smtp_ssl: bool + min_severity: str + source: str # env | db + + @property + def configured(self) -> bool: + return bool(self.smtp_host.strip() and self.mail_from.strip() and self.mail_to.strip()) + + @property + def active(self) -> bool: + return self.enabled and self.configured + + def _telegram_from_env() -> TelegramConfig: settings = get_settings() return TelegramConfig( @@ -211,3 +235,121 @@ def upsert_webhook_channel( db.commit() db.refresh(row) return get_effective_webhook_config(db) + + +def _email_from_env() -> EmailConfig: + settings = get_settings() + return EmailConfig( + enabled=bool(settings.smtp_enabled), + smtp_host=settings.smtp_host.strip(), + smtp_port=int(settings.smtp_port or 587), + smtp_user=settings.smtp_user.strip(), + smtp_password=settings.smtp_password, + mail_from=settings.smtp_from.strip(), + mail_to=settings.smtp_to.strip(), + smtp_starttls=bool(settings.smtp_starttls), + smtp_ssl=bool(settings.smtp_ssl), + min_severity=settings.smtp_min_severity.strip() or "high", + source="env", + ) + + +def get_effective_email_config(db: Session | None = None) -> EmailConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_email_config(session) + finally: + session.close() + + row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_EMAIL)) + env_cfg = _email_from_env() + if row is None: + return env_cfg + + host = (row.smtp_host or "").strip() or env_cfg.smtp_host + port = row.smtp_port if row.smtp_port is not None else env_cfg.smtp_port + user = (row.smtp_user or "").strip() or env_cfg.smtp_user + password = (row.smtp_password or "") or env_cfg.smtp_password + mail_from = (row.mail_from or "").strip() or env_cfg.mail_from + mail_to = (row.mail_to or "").strip() or env_cfg.mail_to + min_sev = (row.min_severity or "").strip() or env_cfg.min_severity + if min_sev not in VALID_SEVERITIES: + min_sev = env_cfg.min_severity + + return EmailConfig( + enabled=bool(row.enabled), + smtp_host=host, + smtp_port=int(port or 587), + smtp_user=user, + smtp_password=password, + mail_from=mail_from, + mail_to=mail_to, + smtp_starttls=bool(row.smtp_starttls), + smtp_ssl=bool(row.smtp_ssl), + min_severity=min_sev, + source="db", + ) + + +def upsert_email_channel( + db: Session, + *, + enabled: bool | None = None, + smtp_host: str | None = None, + smtp_port: int | None = None, + smtp_user: str | None = None, + smtp_password: str | None = None, + mail_from: str | None = None, + mail_to: str | None = None, + smtp_starttls: bool | None = None, + smtp_ssl: bool | None = None, + min_severity: str | None = None, +) -> EmailConfig: + row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_EMAIL)) + env_cfg = _email_from_env() + + if row is None: + row = NotificationChannel( + channel=CHANNEL_EMAIL, + enabled=env_cfg.enabled, + min_severity=env_cfg.min_severity, + smtp_host=env_cfg.smtp_host or None, + smtp_port=env_cfg.smtp_port, + smtp_user=env_cfg.smtp_user or None, + smtp_password=env_cfg.smtp_password or None, + mail_from=env_cfg.mail_from or None, + mail_to=env_cfg.mail_to or None, + smtp_starttls=env_cfg.smtp_starttls, + smtp_ssl=env_cfg.smtp_ssl, + ) + db.add(row) + + if enabled is not None: + row.enabled = enabled + if min_severity is not None: + if min_severity not in VALID_SEVERITIES: + raise ValueError(f"invalid min_severity: {min_severity}") + row.min_severity = min_severity + if smtp_host is not None and smtp_host.strip(): + row.smtp_host = smtp_host.strip() + if smtp_port is not None: + row.smtp_port = smtp_port + if smtp_user is not None: + row.smtp_user = smtp_user.strip() or None + if smtp_password is not None and smtp_password.strip(): + row.smtp_password = smtp_password + if mail_from is not None and mail_from.strip(): + row.mail_from = mail_from.strip() + if mail_to is not None and mail_to.strip(): + row.mail_to = mail_to.strip() + if smtp_starttls is not None: + row.smtp_starttls = smtp_starttls + if smtp_ssl is not None: + row.smtp_ssl = smtp_ssl + + db.commit() + db.refresh(row) + return get_effective_email_config(db) diff --git a/backend/app/services/notify_dispatch.py b/backend/app/services/notify_dispatch.py index 83b6b7a..6b88645 100644 --- a/backend/app/services/notify_dispatch.py +++ b/backend/app/services/notify_dispatch.py @@ -3,14 +3,16 @@ from sqlalchemy.orm import Session from app.models import Event, Problem -from app.services import telegram_notify, webhook_notify +from app.services import email_notify, telegram_notify, webhook_notify def notify_event(event: Event, *, db: Session | None = None) -> None: telegram_notify.notify_event(event, db=db) webhook_notify.notify_event(event, db=db) + email_notify.notify_event(event, db=db) def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: telegram_notify.notify_problem(problem, event, db=db) webhook_notify.notify_problem(problem, event, db=db) + email_notify.notify_problem(problem, event, db=db) diff --git a/backend/tests/test_email_notify.py b/backend/tests/test_email_notify.py new file mode 100644 index 0000000..d92ad5b --- /dev/null +++ b/backend/tests/test_email_notify.py @@ -0,0 +1,71 @@ +"""Tests for SAC email notification helpers.""" + +from datetime import datetime, timezone +from unittest.mock import patch + +from app.models import Event +from app.services import email_notify +from app.services.notification_settings import EmailConfig + + +def test_notify_event_respects_min_severity(): + sent: list[tuple[str, str]] = [] + + def fake_send(subject: str, body: str, **kwargs) -> None: + sent.append((subject, body)) + + event = Event( + event_id="00000000-0000-4000-8000-000000000301", + host_id=1, + occurred_at=datetime(2026, 5, 29, 14, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="failed", + summary="smtp test", + payload={}, + ) + + cfg = EmailConfig( + enabled=True, + smtp_host="smtp.example.com", + smtp_port=587, + smtp_user="u", + smtp_password="p", + mail_from="sac@example.com", + mail_to="admin@example.com", + smtp_starttls=True, + smtp_ssl=False, + min_severity="high", + source="env", + ) + with patch.object(email_notify, "get_effective_email_config", return_value=cfg): + with patch.object(email_notify, "send_email_message", side_effect=fake_send): + email_notify.notify_event(event) + assert sent == [] + + cfg_warn = EmailConfig( + enabled=True, + smtp_host="smtp.example.com", + smtp_port=587, + smtp_user="", + smtp_password="", + mail_from="sac@example.com", + mail_to="admin@example.com", + smtp_starttls=True, + smtp_ssl=False, + min_severity="warning", + source="env", + ) + with patch.object(email_notify, "get_effective_email_config", return_value=cfg_warn): + with patch.object(email_notify, "send_email_message", side_effect=fake_send): + email_notify.notify_event(event) + assert len(sent) == 1 + + +def test_parse_mail_recipients(): + assert email_notify.parse_mail_recipients("a@x.com; b@x.com, c@x.com") == [ + "a@x.com", + "b@x.com", + "c@x.com", + ] diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py index e7297f2..1739dc7 100644 --- a/backend/tests/test_settings_api.py +++ b/backend/tests/test_settings_api.py @@ -24,6 +24,65 @@ def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatc assert body["telegram"]["bot_token_hint"].endswith("ghij") assert "webhook" in body assert body["webhook"]["enabled"] is False + assert "email" in body + assert body["email"]["enabled"] is False + + +def test_put_email_settings_persists_db(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SMTP_ENABLED", "false") + monkeypatch.setenv("SMTP_HOST", "") + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/notifications/email", + headers=jwt_headers, + json={ + "enabled": True, + "min_severity": "warning", + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_user": "monitor", + "smtp_password": "secret", + "mail_from": "sac@example.com", + "mail_to": "admin@example.com,ops@example.com", + "smtp_starttls": True, + "smtp_ssl": False, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["source"] == "db" + assert body["configured"] is True + assert body["smtp_port"] == 587 + + row = db_session.get(NotificationChannel, "email") + assert row is not None + assert row.smtp_host == "smtp.example.com" + assert row.mail_from == "sac@example.com" + + +def test_post_email_test_success(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("SMTP_ENABLED", "false") + get_settings.cache_clear() + + db_session.add( + NotificationChannel( + channel="email", + enabled=True, + min_severity="warning", + smtp_host="smtp.example.com", + smtp_port=587, + mail_from="sac@example.com", + mail_to="admin@example.com", + ) + ) + db_session.commit() + + with patch("app.api.v1.settings.send_email_test_message") as mock_test: + response = client.post("/api/v1/settings/notifications/email/test", headers=jwt_headers) + assert response.status_code == 200 + assert response.json()["ok"] is True + mock_test.assert_called_once() def test_put_webhook_settings_persists_db(jwt_headers, client, db_session, monkeypatch): diff --git a/deploy/env.native.example b/deploy/env.native.example index 0763819..a08e050 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -32,6 +32,18 @@ WEBHOOK_SECRET_HEADER= WEBHOOK_SECRET= WEBHOOK_MIN_SEVERITY=high +# SMTP email (F-NOT-01) +SMTP_ENABLED=false +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +SMTP_TO= +SMTP_STARTTLS=true +SMTP_SSL=false +SMTP_MIN_SEVERITY=high + # Статус хоста по agent.heartbeat SAC_HEARTBEAT_STALE_MINUTES=780 diff --git a/docs/operations-prod-status.md b/docs/operations-prod-status.md index f3e601f..c45364f 100644 --- a/docs/operations-prod-status.md +++ b/docs/operations-prod-status.md @@ -50,7 +50,7 @@ sudo /opt/sac-deploy.sh ## Следующие шаги (2026-05-29) -1. **Деплой SAC v0.5.0** — `sudo /opt/sac-deploy.sh` (включает `alembic upgrade head`, миграция **`004_notification_channels`**) +1. **Деплой SAC v0.5.0** — `sudo /opt/sac-deploy.sh` (миграции **`004`–`006`**: Telegram, webhook, SMTP) 2. **notif-02** — прогон [testing-e2e-checklist.md](testing-e2e-checklist.md) § «SAC Telegram» на prod 3. **Настройки UI** — `/settings`: Telegram `warning+`, кнопка «Проверить Telegram» 4. **RDP 1.2.20-SAC** — NETLOGON + `Deploy-LoginMonitor.ps1` на пилотных хостах diff --git a/docs/todo-2026-05-29.md b/docs/todo-2026-05-29.md index aa9eac4..b4ac358 100644 --- a/docs/todo-2026-05-29.md +++ b/docs/todo-2026-05-29.md @@ -12,7 +12,7 @@ | Порог severity | `telegram_min_severity` (по умолчанию **`high`**) | События **`info`** (успешный RDP `rdp.login.success`) **не** уходят в TG, пока порог не снизить до `warning` | | Вызов при ingest | `events.py` → `notify_event` / `notify_problem` | Шаблон короткий («🚨 SAC событие»), не как у агента | | UI «Настройки» | `/settings`, GET/PUT Telegram | БД `notification_channels` (миграция `004`) + fallback на `sac-api.env` | -| Email / webhook | TZ F-NOT-01 | Webhook ✅; SMTP — `notif-20` | +| Email / webhook | TZ F-NOT-01 | Email ✅, Webhook ✅ | **Вывод на завтра:** для exclusive нужно (1) включить и настроить TG на сервере SAC **или** (2) сделать раздел **«Настройки»** в UI + хранение каналов; порог **`warning` и выше** для событий и problems. @@ -44,7 +44,7 @@ | ID | Задача | Критерий | |----|--------|----------| -| `notif-20` | SMTP: host, port, user, from, to, TLS — по аналогии с RDP `login_monitor.settings` | Отправка тестового письма | +| `notif-20` | SMTP: host, port, user, from, to, TLS — по аналогии с RDP `login_monitor.settings` | ✅ PUT/test API + UI + ingest | | `notif-21` | Webhook: URL, optional secret header, JSON payload (event/problem) | ✅ PUT/test API + UI + ingest | | `notif-22` | TZ F-NOT-02 урезанно: правило «severity ≥ X → каналы» без сложного конструктора | Одна строка в настройках | diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index ef91041..912ba62 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -117,6 +117,74 @@
JSON POST: kind = event | problem | test; те же пороги severity, что у Telegram.