diff --git a/backend/alembic/versions/005_notification_webhook.py b/backend/alembic/versions/005_notification_webhook.py new file mode 100644 index 0000000..4f84675 --- /dev/null +++ b/backend/alembic/versions/005_notification_webhook.py @@ -0,0 +1,30 @@ +"""notification_channels: webhook columns + +Revision ID: 005 +Revises: 004 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "005" +down_revision: Union[str, None] = "004" +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("webhook_url", sa.Text(), nullable=True)) + op.add_column( + "notification_channels", + sa.Column("webhook_secret_header", sa.String(length=64), nullable=True), + ) + op.add_column("notification_channels", sa.Column("webhook_secret", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("notification_channels", "webhook_secret") + op.drop_column("notification_channels", "webhook_secret_header") + op.drop_column("notification_channels", "webhook_url") diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 6ba4902..c35d945 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -17,7 +17,7 @@ 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.telegram_notify import notify_event, notify_problem +from app.services.notify_dispatch import notify_event, notify_problem router = APIRouter(prefix="/events", tags=["events"]) logger = logging.getLogger("sac.ingest") diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index c38158a..83cd5bd 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -1,106 +1,190 @@ -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, - get_effective_telegram_config, - upsert_telegram_channel, -) -from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_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:]}" - - -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 NotificationSettingsResponse(BaseModel): - telegram: TelegramSettingsResponse - - -class TelegramSettingsUpdate(BaseModel): - enabled: bool - min_severity: str - bot_token: str | None = None - chat_id: str | None = None - - -class TelegramTestResponse(BaseModel): - ok: bool = True - message: str = "Тестовое сообщение отправлено в Telegram" - - -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, - ) - - -@router.get("/notifications", response_model=NotificationSettingsResponse) -def get_notification_settings( - db: Session = Depends(get_db), - _user: str = Depends(get_current_user), -) -> NotificationSettingsResponse: - cfg = get_effective_telegram_config(db) - return NotificationSettingsResponse(telegram=_telegram_to_response(cfg)) - - -@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.post("/notifications/telegram/test", response_model=TelegramTestResponse) -def test_telegram_settings( - db: Session = Depends(get_db), - _user: str = Depends(get_current_user), -) -> TelegramTestResponse: - 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 TelegramTestResponse() +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") diff --git a/backend/app/config.py b/backend/app/config.py index ffd6914..4a90b40 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -50,7 +50,13 @@ class Settings(BaseSettings): telegram_chat_id: str = "" telegram_min_severity: str = "high" - # Порог «живости» агента: agent.heartbeat раз в ~12 ч (ssh-monitor) + webhook_enabled: bool = False + webhook_url: str = "" + webhook_secret_header: str = "" + webhook_secret: str = "" + webhook_min_severity: str = "high" + + # Порог «живости» агента sac_heartbeat_stale_minutes: int = 780 # Окно корреляции Problems: host + type + rule в одном open Problem diff --git a/backend/app/models/notification_channel.py b/backend/app/models/notification_channel.py index 973f886..6b29efb 100644 --- a/backend/app/models/notification_channel.py +++ b/backend/app/models/notification_channel.py @@ -14,6 +14,9 @@ class NotificationChannel(Base): min_severity: Mapped[str] = mapped_column(String(16), default="warning") bot_token: Mapped[str | None] = mapped_column(Text, nullable=True) chat_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + 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) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) diff --git a/backend/app/services/notification_settings.py b/backend/app/services/notification_settings.py index a53f4c5..973b89a 100644 --- a/backend/app/services/notification_settings.py +++ b/backend/app/services/notification_settings.py @@ -12,6 +12,7 @@ from app.models.notification_channel import NotificationChannel VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"}) CHANNEL_TELEGRAM = "telegram" +CHANNEL_WEBHOOK = "webhook" @dataclass(frozen=True) @@ -31,6 +32,24 @@ class TelegramConfig: return self.enabled and self.configured +@dataclass(frozen=True) +class WebhookConfig: + enabled: bool + url: str + secret_header: str + secret: str + min_severity: str + source: str # env | db + + @property + def configured(self) -> bool: + return bool(self.url.strip()) + + @property + def active(self) -> bool: + return self.enabled and self.configured + + def _telegram_from_env() -> TelegramConfig: settings = get_settings() return TelegramConfig( @@ -107,3 +126,88 @@ def upsert_telegram_channel( db.commit() db.refresh(row) return get_effective_telegram_config(db) + + +def _webhook_from_env() -> WebhookConfig: + settings = get_settings() + return WebhookConfig( + enabled=bool(settings.webhook_enabled), + url=settings.webhook_url.strip(), + secret_header=settings.webhook_secret_header.strip(), + secret=settings.webhook_secret.strip(), + min_severity=settings.webhook_min_severity.strip() or "high", + source="env", + ) + + +def get_effective_webhook_config(db: Session | None = None) -> WebhookConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_webhook_config(session) + finally: + session.close() + + row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_WEBHOOK)) + env_cfg = _webhook_from_env() + if row is None: + return env_cfg + + url = (row.webhook_url or "").strip() or env_cfg.url + secret_header = (row.webhook_secret_header or "").strip() or env_cfg.secret_header + secret = (row.webhook_secret or "").strip() or env_cfg.secret + 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 WebhookConfig( + enabled=bool(row.enabled), + url=url, + secret_header=secret_header, + secret=secret, + min_severity=min_sev, + source="db", + ) + + +def upsert_webhook_channel( + db: Session, + *, + enabled: bool | None = None, + url: str | None = None, + secret_header: str | None = None, + secret: str | None = None, + min_severity: str | None = None, +) -> WebhookConfig: + row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_WEBHOOK)) + env_cfg = _webhook_from_env() + + if row is None: + row = NotificationChannel( + channel=CHANNEL_WEBHOOK, + enabled=env_cfg.enabled, + min_severity=env_cfg.min_severity, + webhook_url=env_cfg.url or None, + webhook_secret_header=env_cfg.secret_header or None, + webhook_secret=env_cfg.secret or None, + ) + 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 url is not None and url.strip(): + row.webhook_url = url.strip() + if secret_header is not None: + row.webhook_secret_header = secret_header.strip() or None + if secret is not None and secret.strip(): + row.webhook_secret = secret.strip() + + db.commit() + db.refresh(row) + return get_effective_webhook_config(db) diff --git a/backend/app/services/notify_dispatch.py b/backend/app/services/notify_dispatch.py new file mode 100644 index 0000000..83b6b7a --- /dev/null +++ b/backend/app/services/notify_dispatch.py @@ -0,0 +1,16 @@ +"""Dispatch ingest notifications to all configured channels.""" + +from sqlalchemy.orm import Session + +from app.models import Event, Problem +from app.services import 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) + + +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) diff --git a/backend/app/services/webhook_notify.py b/backend/app/services/webhook_notify.py new file mode 100644 index 0000000..cf508f1 --- /dev/null +++ b/backend/app/services/webhook_notify.py @@ -0,0 +1,140 @@ +"""Generic JSON webhook notifications.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx +from sqlalchemy.orm import Session + +from app.models import Event, Problem +from app.services.notification_settings import WebhookConfig, get_effective_webhook_config +from app.services.telegram_notify import SEVERITY_ORDER + +logger = logging.getLogger(__name__) + + +class WebhookNotConfiguredError(Exception): + """Webhook disabled or missing URL.""" + + +class WebhookSendError(Exception): + def __init__(self, message: str, *, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code + + +def _severity_value(value: str) -> int: + return SEVERITY_ORDER.get(value, 0) + + +def _should_notify_severity(severity: str, *, config: WebhookConfig) -> bool: + return _severity_value(severity) >= _severity_value(config.min_severity) + + +def _host_payload(entity: Event | Problem) -> dict[str, Any]: + host = entity.host + if host is None: + return {"hostname": "unknown", "display_name": None} + return {"hostname": host.hostname, "display_name": host.display_name} + + +def build_event_payload(event: Event) -> dict[str, Any]: + return { + "kind": "event", + "event_id": event.event_id, + "type": event.type, + "severity": event.severity, + "category": event.category, + "title": event.title, + "summary": event.summary, + "host": _host_payload(event), + "occurred_at": event.occurred_at.isoformat() if event.occurred_at else None, + } + + +def build_problem_payload(problem: Problem, event: Event | None = None) -> dict[str, Any]: + payload: dict[str, Any] = { + "kind": "problem", + "problem_id": problem.id, + "rule_id": problem.rule_id, + "severity": problem.severity, + "status": problem.status, + "title": problem.title, + "summary": problem.summary, + "host": _host_payload(problem), + "opened_at": problem.opened_at.isoformat() if problem.opened_at else None, + } + if event is not None: + payload["trigger_event"] = { + "event_id": event.event_id, + "type": event.type, + "severity": event.severity, + } + return payload + + +def send_webhook_payload(payload: dict[str, Any], *, config: WebhookConfig | None = None, force: bool = False) -> None: + cfg = config or get_effective_webhook_config() + if not force and not cfg.active: + return + if not cfg.url: + if force: + raise WebhookNotConfiguredError("Webhook: не задан URL") + return + + headers = {"Content-Type": "application/json", "User-Agent": "SecurityAlertCenter/1.0"} + if cfg.secret_header and cfg.secret: + headers[cfg.secret_header] = cfg.secret + + try: + with httpx.Client(timeout=10.0) as client: + response = client.post(cfg.url, json=payload, headers=headers) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:200] if exc.response is not None else str(exc) + raise WebhookSendError( + f"Webhook HTTP {exc.response.status_code}: {detail}", + status_code=exc.response.status_code, + ) from exc + except Exception as exc: + logger.exception("webhook send failed") + raise WebhookSendError(str(exc)) from exc + + +def send_webhook_test_message(*, config: WebhookConfig | None = None) -> None: + cfg = config or get_effective_webhook_config() + if not cfg.enabled: + raise WebhookNotConfiguredError("Webhook отключён (enabled=false)") + if not cfg.configured: + raise WebhookNotConfiguredError("Не задан webhook URL") + send_webhook_payload( + { + "kind": "test", + "message": "SAC webhook test", + "source": "security-alert-center", + }, + config=cfg, + force=True, + ) + + +def notify_event(event: Event, *, db: Session | None = None) -> None: + cfg = get_effective_webhook_config(db) + if not cfg.active or not _should_notify_severity(event.severity, config=cfg): + return + try: + send_webhook_payload(build_event_payload(event), config=cfg) + except WebhookSendError: + logger.exception("notify_event webhook failed event_id=%s", event.event_id) + + +def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: + cfg = get_effective_webhook_config(db) + if not cfg.active or not _should_notify_severity(problem.severity, config=cfg): + return + try: + send_webhook_payload(build_problem_payload(problem, event), config=cfg) + except WebhookSendError: + logger.exception("notify_problem webhook failed problem_id=%s", problem.id) diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py index f585126..e7297f2 100644 --- a/backend/tests/test_settings_api.py +++ b/backend/tests/test_settings_api.py @@ -22,6 +22,56 @@ def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatc assert body["telegram"]["min_severity"] == "warning" assert body["telegram"]["source"] == "env" assert body["telegram"]["bot_token_hint"].endswith("ghij") + assert "webhook" in body + assert body["webhook"]["enabled"] is False + + +def test_put_webhook_settings_persists_db(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("WEBHOOK_ENABLED", "false") + monkeypatch.setenv("WEBHOOK_URL", "") + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/notifications/webhook", + headers=jwt_headers, + json={ + "enabled": True, + "min_severity": "warning", + "url": "https://example.com/hooks/sac", + "secret_header": "X-SAC-Token", + "secret": "supersecret", + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["source"] == "db" + assert body["configured"] is True + + row = db_session.get(NotificationChannel, "webhook") + assert row is not None + assert row.webhook_url == "https://example.com/hooks/sac" + assert row.webhook_secret_header == "X-SAC-Token" + + +def test_post_webhook_test_success(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("WEBHOOK_ENABLED", "false") + get_settings.cache_clear() + + db_session.add( + NotificationChannel( + channel="webhook", + enabled=True, + min_severity="warning", + webhook_url="https://example.com/hook", + ) + ) + db_session.commit() + + with patch("app.api.v1.settings.send_webhook_test_message") as mock_test: + response = client.post("/api/v1/settings/notifications/webhook/test", headers=jwt_headers) + assert response.status_code == 200 + assert response.json()["ok"] is True + mock_test.assert_called_once() def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monkeypatch): diff --git a/backend/tests/test_webhook_notify.py b/backend/tests/test_webhook_notify.py new file mode 100644 index 0000000..63bd729 --- /dev/null +++ b/backend/tests/test_webhook_notify.py @@ -0,0 +1,55 @@ +"""Tests for SAC webhook notification helpers.""" + +from datetime import datetime, timezone +from unittest.mock import patch + +from app.models import Event +from app.services import webhook_notify +from app.services.notification_settings import WebhookConfig + + +def test_notify_event_respects_min_severity(): + sent: list[dict] = [] + + def fake_send(payload: dict, **kwargs) -> None: + sent.append(payload) + + event = Event( + event_id="00000000-0000-4000-8000-000000000201", + host_id=1, + occurred_at=datetime(2026, 5, 29, 12, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="failed", + summary="e2e", + payload={}, + ) + + cfg = WebhookConfig( + enabled=True, + url="https://example.com/hook", + secret_header="", + secret="", + min_severity="high", + source="env", + ) + with patch.object(webhook_notify, "get_effective_webhook_config", return_value=cfg): + with patch.object(webhook_notify, "send_webhook_payload", side_effect=fake_send): + webhook_notify.notify_event(event) + assert sent == [] + + cfg_high = WebhookConfig( + enabled=True, + url="https://example.com/hook", + secret_header="", + secret="", + min_severity="warning", + source="env", + ) + event.severity = "warning" + with patch.object(webhook_notify, "get_effective_webhook_config", return_value=cfg_high): + with patch.object(webhook_notify, "send_webhook_payload", side_effect=fake_send): + webhook_notify.notify_event(event) + assert len(sent) == 1 + assert sent[0]["kind"] == "event" diff --git a/deploy/env.native.example b/deploy/env.native.example index 17cf7b8..0763819 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -25,7 +25,14 @@ TELEGRAM_CHAT_ID= # exclusive: warning (failed login, problems); prod noise-only: high TELEGRAM_MIN_SEVERITY=high -# Статус хоста по agent.heartbeat (минуты; ssh-monitor шлёт ~раз в 12 ч) +# Generic webhook (JSON POST), F-NOT-01 +WEBHOOK_ENABLED=false +WEBHOOK_URL= +WEBHOOK_SECRET_HEADER= +WEBHOOK_SECRET= +WEBHOOK_MIN_SEVERITY=high + +# Статус хоста по agent.heartbeat SAC_HEARTBEAT_STALE_MINUTES=780 # Корреляция Problems: host + type + rule в одном open Problem (минуты) diff --git a/docs/todo-2026-05-29.md b/docs/todo-2026-05-29.md index 03d9ba2..aa9eac4 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 | **Не реализованы** в backend | +| Email / webhook | TZ F-NOT-01 | Webhook ✅; SMTP — `notif-20` | **Вывод на завтра:** для exclusive нужно (1) включить и настроить TG на сервере SAC **или** (2) сделать раздел **«Настройки»** в UI + хранение каналов; порог **`warning` и выше** для событий и problems. @@ -45,7 +45,7 @@ | ID | Задача | Критерий | |----|--------|----------| | `notif-20` | SMTP: host, port, user, from, to, TLS — по аналогии с RDP `login_monitor.settings` | Отправка тестового письма | -| `notif-21` | Webhook: URL, optional secret header, JSON payload (event/problem) | POST на httpbin/staging | +| `notif-21` | Webhook: URL, optional secret header, JSON payload (event/problem) | ✅ PUT/test API + UI + ingest | | `notif-22` | TZ F-NOT-02 урезанно: правило «severity ≥ X → каналы» без сложного конструктора | Одна строка в настройках | ### P3 — Качество сообщений (можно сдвинуть) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index cccb682..ef91041 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -2,66 +2,122 @@
- Каналы оповещений SAC. Настройки Telegram сохраняются в БД (источник db) или берутся из
- sac-api.env (env), пока запись не создана.
+ Каналы оповещений SAC. Значения в БД (source=db) или из sac-api.env (env),
+ пока запись не создана через UI.
{{ error }}
{{ success }}
Загрузка…
-
- Для UseSAC=exclusive рекомендуется warning — failed login и problems, без
- успешных RDP (info).
-
JSON POST: kind = event | problem | test; те же пороги severity, что у Telegram.