feat: SAC Telegram settings view, notify_problem severity gate, exclusive docs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, dashboards, events, health, hosts, problems, stream
|
||||
from app.api.v1 import auth, dashboards, events, health, hosts, problems, settings, stream
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
@@ -9,4 +9,5 @@ api_router.include_router(events.router)
|
||||
api_router.include_router(hosts.router)
|
||||
api_router.include_router(problems.router)
|
||||
api_router.include_router(dashboards.router)
|
||||
api_router.include_router(settings.router)
|
||||
api_router.include_router(stream.router)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
from app.config import get_settings
|
||||
|
||||
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(default="env", description="env until UI persistence (notif-12)")
|
||||
|
||||
|
||||
class NotificationSettingsResponse(BaseModel):
|
||||
telegram: TelegramSettingsResponse
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=NotificationSettingsResponse)
|
||||
def get_notification_settings(
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> NotificationSettingsResponse:
|
||||
settings = get_settings()
|
||||
token = settings.telegram_bot_token.strip()
|
||||
chat_id = settings.telegram_chat_id.strip()
|
||||
configured = bool(token and chat_id)
|
||||
return NotificationSettingsResponse(
|
||||
telegram=TelegramSettingsResponse(
|
||||
enabled=bool(settings.telegram_enabled and configured),
|
||||
configured=configured,
|
||||
chat_id_hint=_mask_secret(chat_id),
|
||||
bot_token_hint=_mask_secret(token),
|
||||
min_severity=settings.telegram_min_severity,
|
||||
source="env",
|
||||
)
|
||||
)
|
||||
@@ -37,10 +37,14 @@ def _send_text(message: str) -> None:
|
||||
logger.exception("telegram send failed")
|
||||
|
||||
|
||||
def notify_event(event: Event) -> None:
|
||||
def _should_notify_severity(severity: str) -> bool:
|
||||
settings = get_settings()
|
||||
min_level = _severity_value(settings.telegram_min_severity)
|
||||
if _severity_value(event.severity) < min_level:
|
||||
return _severity_value(severity) >= min_level
|
||||
|
||||
|
||||
def notify_event(event: Event) -> None:
|
||||
if not _should_notify_severity(event.severity):
|
||||
return
|
||||
host = event.host.hostname if event.host else "unknown"
|
||||
message = (
|
||||
@@ -55,6 +59,8 @@ def notify_event(event: Event) -> None:
|
||||
|
||||
|
||||
def notify_problem(problem: Problem, event: Event | None = None) -> None:
|
||||
if not _should_notify_severity(problem.severity):
|
||||
return
|
||||
host = problem.host.hostname if problem.host else "unknown"
|
||||
related = ""
|
||||
if event is not None:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""GET /api/v1/settings/notifications"""
|
||||
|
||||
from app.services.telegram_notify import get_settings
|
||||
|
||||
|
||||
def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatch):
|
||||
monkeypatch.setenv("TELEGRAM_ENABLED", "true")
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567890:ABCDEFghij")
|
||||
monkeypatch.setenv("TELEGRAM_CHAT_ID", "2843230")
|
||||
monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "warning")
|
||||
get_settings.cache_clear()
|
||||
|
||||
response = client.get("/api/v1/settings/notifications", headers=jwt_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["telegram"]["enabled"] is True
|
||||
assert body["telegram"]["configured"] is True
|
||||
assert body["telegram"]["min_severity"] == "warning"
|
||||
assert body["telegram"]["source"] == "env"
|
||||
assert body["telegram"]["bot_token_hint"].endswith("ghij")
|
||||
assert "1234567890" not in body["telegram"]["bot_token_hint"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests for SAC Telegram notification helpers."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import Event, Host, Problem
|
||||
from app.services import telegram_notify
|
||||
|
||||
|
||||
def test_notify_event_respects_min_severity(monkeypatch):
|
||||
sent: list[str] = []
|
||||
|
||||
def fake_send(message: str) -> None:
|
||||
sent.append(message)
|
||||
|
||||
monkeypatch.setenv("TELEGRAM_ENABLED", "true")
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-token")
|
||||
monkeypatch.setenv("TELEGRAM_CHAT_ID", "123")
|
||||
monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "warning")
|
||||
telegram_notify.get_settings.cache_clear()
|
||||
|
||||
event = Event(
|
||||
event_id="00000000-0000-4000-8000-000000000001",
|
||||
host_id=1,
|
||||
occurred_at=datetime(2026, 5, 28, 12, 0, tzinfo=timezone.utc),
|
||||
category="auth",
|
||||
type="rdp.login.success",
|
||||
severity="info",
|
||||
title="ok",
|
||||
summary="info event",
|
||||
payload={},
|
||||
)
|
||||
|
||||
with patch.object(telegram_notify, "_send_text", side_effect=fake_send):
|
||||
telegram_notify.notify_event(event)
|
||||
assert sent == []
|
||||
|
||||
event.severity = "warning"
|
||||
with patch.object(telegram_notify, "_send_text", side_effect=fake_send):
|
||||
telegram_notify.notify_event(event)
|
||||
assert len(sent) == 1
|
||||
|
||||
|
||||
def test_notify_problem_respects_min_severity(monkeypatch):
|
||||
sent: list[str] = []
|
||||
|
||||
def fake_send(message: str) -> None:
|
||||
sent.append(message)
|
||||
|
||||
monkeypatch.setenv("TELEGRAM_ENABLED", "true")
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-token")
|
||||
monkeypatch.setenv("TELEGRAM_CHAT_ID", "123")
|
||||
monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "high")
|
||||
telegram_notify.get_settings.cache_clear()
|
||||
|
||||
problem = Problem(
|
||||
title="brute",
|
||||
summary="burst",
|
||||
severity="warning",
|
||||
status="open",
|
||||
fingerprint="fp1",
|
||||
)
|
||||
|
||||
with patch.object(telegram_notify, "_send_text", side_effect=fake_send):
|
||||
telegram_notify.notify_problem(problem)
|
||||
assert sent == []
|
||||
|
||||
problem.severity = "high"
|
||||
with patch.object(telegram_notify, "_send_text", side_effect=fake_send):
|
||||
telegram_notify.notify_problem(problem)
|
||||
assert len(sent) == 1
|
||||
@@ -18,9 +18,11 @@ SAC_ADMIN_USERNAME=admin
|
||||
SAC_ADMIN_PASSWORD=
|
||||
|
||||
# Telegram notifications from SAC (phase 1C.4)
|
||||
# При UseSAC=exclusive на агентах — основной канал для оператора; см. docs/agent-integration.md §1.3.1
|
||||
TELEGRAM_ENABLED=false
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
# exclusive: warning (failed login, problems); prod noise-only: high
|
||||
TELEGRAM_MIN_SEVERITY=high
|
||||
|
||||
# Статус хоста по agent.heartbeat (минуты; ssh-monitor шлёт ~раз в 12 ч)
|
||||
|
||||
@@ -46,6 +46,30 @@ $SacSpoolDir = "D:\Soft\Logs\sac-spool"
|
||||
- `UseSAC≠off` — обязательны `SAC_URL`, `SAC_API_KEY`; HTTP `GET {base}/health` OK.
|
||||
- `--check-sac` / `Test-SacConnection` — отправка `agent.test`, ожидание **HTTP 201** (повтор с тем же `event_id` — **409**, тоже успех для spool).
|
||||
|
||||
### 1.3.1. Telegram и email при `UseSAC=exclusive`
|
||||
|
||||
При **`exclusive`** агент **не** шлёт Telegram/email — оператору нужны оповещения **из SAC** (`backend/app/services/telegram_notify.py`).
|
||||
|
||||
На сервере SAC в **`/opt/security-alert-center/config/sac-api.env`** (см. `deploy/env.native.example`):
|
||||
|
||||
```env
|
||||
TELEGRAM_ENABLED=true
|
||||
TELEGRAM_BOT_TOKEN=<bot>
|
||||
TELEGRAM_CHAT_ID=<chat>
|
||||
# warning — failed login, sudo, problems; high — только high/critical
|
||||
TELEGRAM_MIN_SEVERITY=warning
|
||||
```
|
||||
|
||||
| Severity события | `warning` | `high` (по умолчанию в example) |
|
||||
|------------------|-----------|----------------------------------|
|
||||
| `info` (успешный RDP/SSH login) | нет | нет |
|
||||
| `warning` (`*.login.failed`, sudo) | **да** | нет |
|
||||
| `high` / `critical` (ban, problem) | **да** | **да** |
|
||||
|
||||
Problems (`notify_problem`) учитывают тот же порог `TELEGRAM_MIN_SEVERITY`.
|
||||
|
||||
UI **Настройки** (`/settings`) — просмотр маскированной конфигурации; запись в env — до задачи `notif-12` (БД) или правка `sac-api.env` + restart `sac-api`.
|
||||
|
||||
### 1.4. Версии и доставка обновлений
|
||||
|
||||
При **любом** изменении агента, влияющем на SAC или поведение на хосте, поднимайте версию и пушьте в **git.kalinamall.ru**:
|
||||
@@ -53,7 +77,7 @@ $SacSpoolDir = "D:\Soft\Logs\sac-spool"
|
||||
| Агент | Маркер версии | Как хост узнаёт о новой версии |
|
||||
|-------|---------------|--------------------------------|
|
||||
| **ssh-monitor** | `SSH_MONITOR_VERSION` в `ssh-monitor`; `# SAC client release:` в `sac-client.sh` | `update_ssh_monitor.sh`: `git pull` в клоне → сравнение sha256 `ssh-monitor` и `sac-client.sh` |
|
||||
| **RDP-login-monitor** | `$ScriptVersion` в `Login_Monitor.ps1` и **та же** строка в `version.txt` на шаре NETLOGON | `Deploy-LoginMonitor.ps1`: `version.txt` на шаре > `deployed_version.txt` → копирует `Login_Monitor.ps1` и `Sac-Client.ps1` |
|
||||
| **RDP-login-monitor** | `$ScriptVersion` в `Login_Monitor.ps1` и **та же** строка в `version.txt` на шаре NETLOGON | `Deploy-LoginMonitor.ps1`: сверка `version.txt` и SHA256 пакета (`Login_Monitor.ps1`, `Sac-Client.ps1`); при отсутствии SAC в settings — **`UseSAC=dual`** из example; подсказка `# $ServerDisplayName` |
|
||||
|
||||
**Кириллица в SAC (RDP):** до **1.2.8-SAC** `Invoke-WebRequest` мог слать JSON не в UTF-8 — в UI «Отчёты» summary вида `RDP 24?: ??????` вместо `RDP 24ч: сессий …`. Обновите `Sac-Client.ps1` на всех хостах; старые события в БД не пересчитываются, исправятся только новые ingest после деплоя.
|
||||
|
||||
|
||||
@@ -26,18 +26,18 @@
|
||||
|
||||
| ID | Задача | Критерий |
|
||||
|----|--------|----------|
|
||||
| `notif-01` | Зафиксировать в `agent-integration.md` / runbook: при **exclusive** канал оповещений = SAC; рекомендуемый `TELEGRAM_MIN_SEVERITY=warning` (или `high` только для prod-шума) | Документ + пример `sac-api.env` |
|
||||
| `notif-01` | Зафиксировать в `agent-integration.md` / runbook: при **exclusive** канал оповещений = SAC; рекомендуемый `TELEGRAM_MIN_SEVERITY=warning` | ✅ docs + `env.native.example` |
|
||||
| `notif-02` | Проверить на staging: ingest `rdp.login.failed` (warning) → TG; `rdp.login.success` (info) → нет TG при `min=warning` | Чеклист в E2E |
|
||||
| `notif-03` | `notify_problem`: учитывать `telegram_min_severity`; опционально отдельный порог для problems | Unit-тест |
|
||||
| `notif-03` | `notify_problem`: учитывать `telegram_min_severity` | ✅ unit-тест |
|
||||
|
||||
### P1 — UI «Настройки» (каналы)
|
||||
|
||||
| ID | Задача | Критерий |
|
||||
|----|--------|----------|
|
||||
| `notif-10` | Маршрут `/settings`, пункт в сайдбаре (только admin JWT) | Страница открывается |
|
||||
| `notif-11` | Блок **Telegram**: enabled, bot token, chat id, min severity (`info`/`warning`/`high`/`critical`) | GET/PUT API + форма |
|
||||
| `notif-10` | Маршрут `/settings`, пункт в сайдбаре (только admin JWT) | ✅ страница + GET API |
|
||||
| `notif-11` | Блок **Telegram**: enabled, bot token, chat id, min severity | ✅ GET (read-only, маска); PUT — `notif-12` |
|
||||
| `notif-12` | Хранение: **вариант A** — запись в БД `notification_channels` + fallback на env; **вариант B** — только env, UI read-only | Решение в комментарии к PR |
|
||||
| `notif-13` | Секреты: токен не возвращать целиком в GET (маска `***…last4`); запись только при изменении | Без утечки в логах/UI |
|
||||
| `notif-13` | Секреты: токен не возвращать целиком в GET (маска `***…last4`) | ✅ GET settings |
|
||||
| `notif-14` | Кнопка «Проверить Telegram» → тестовое сообщение | 200 / понятная ошибка |
|
||||
|
||||
### P2 — Email и webhook (после Telegram)
|
||||
|
||||
@@ -44,6 +44,7 @@ const navItems = [
|
||||
{ to: "/reports", label: "Отчёты", icon: "▤", match: "prefix" as const },
|
||||
{ to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const },
|
||||
{ to: "/hosts", label: "Хосты", icon: "⬢", match: "exact" as const },
|
||||
{ to: "/settings", label: "Настройки", icon: "⚙", match: "exact" as const },
|
||||
];
|
||||
|
||||
function isActive(item: (typeof navItems)[number]) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import ProblemsView from "./views/ProblemsView.vue";
|
||||
import ProblemDetailView from "./views/ProblemDetailView.vue";
|
||||
import DashboardView from "./views/DashboardView.vue";
|
||||
import ReportsView from "./views/ReportsView.vue";
|
||||
import SettingsView from "./views/SettingsView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -21,6 +22,7 @@ const router = createRouter({
|
||||
{ path: "/hosts", component: HostsView },
|
||||
{ path: "/problems", component: ProblemsView },
|
||||
{ path: "/problems/:id", component: ProblemDetailView, props: true },
|
||||
{ path: "/settings", component: SettingsView },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h1>Настройки</h1>
|
||||
<p class="settings-intro">
|
||||
Каналы оповещений SAC. Сейчас конфигурация читается из <code>sac-api.env</code> на сервере;
|
||||
редактирование через UI — в разработке (<code>notif-12</code>).
|
||||
</p>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading">Загрузка…</p>
|
||||
|
||||
<section v-else-if="data" class="card settings-card">
|
||||
<h2>Telegram</h2>
|
||||
<dl class="settings-dl">
|
||||
<dt>Включён</dt>
|
||||
<dd>{{ data.telegram.enabled ? "да" : "нет" }}</dd>
|
||||
<dt>Настроен (token + chat)</dt>
|
||||
<dd>{{ data.telegram.configured ? "да" : "нет" }}</dd>
|
||||
<dt>Мин. severity</dt>
|
||||
<dd><code>{{ data.telegram.min_severity }}</code></dd>
|
||||
<dt>Bot token</dt>
|
||||
<dd>{{ data.telegram.bot_token_hint ?? "—" }}</dd>
|
||||
<dt>Chat ID</dt>
|
||||
<dd>{{ data.telegram.chat_id_hint ?? "—" }}</dd>
|
||||
<dt>Источник</dt>
|
||||
<dd><code>{{ data.telegram.source }}</code></dd>
|
||||
</dl>
|
||||
<p class="settings-hint">
|
||||
Для <strong>UseSAC=exclusive</strong> на агентах задайте
|
||||
<code>TELEGRAM_ENABLED=true</code> и <code>TELEGRAM_MIN_SEVERITY=warning</code> в
|
||||
<code>sac-api.env</code>, затем перезапустите <code>sac-api</code>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { apiFetch } from "../api";
|
||||
|
||||
interface TelegramSettings {
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
chat_id_hint: string | null;
|
||||
bot_token_hint: string | null;
|
||||
min_severity: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
telegram: TelegramSettings;
|
||||
}
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const data = ref<NotificationSettings | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
data.value = await apiFetch<NotificationSettings>("/api/v1/settings/notifications");
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.settings-intro {
|
||||
color: #9aa4b2;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.settings-card h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.settings-dl {
|
||||
display: grid;
|
||||
grid-template-columns: 11rem 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.settings-dl dt {
|
||||
color: #9aa4b2;
|
||||
}
|
||||
|
||||
.settings-dl dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
font-size: 0.9rem;
|
||||
color: #9aa4b2;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user