feat: ingest burst hardening — defer lifecycle/auth, workers, nginx (v0.5.5)

Defer notify_lifecycle and notify_auth_login after commit; SAC_UVICORN_WORKERS via sac-api-start.sh; nginx /api/v1/events timeout; mass-update confirm in Hosts UI.
This commit is contained in:
PTah
2026-07-08 13:16:13 +10:00
parent 1ad01534f1
commit b148b558c3
14 changed files with 189 additions and 12 deletions
+10 -2
View File
@@ -52,7 +52,9 @@ from app.services.notify_dispatch import (
notify_lifecycle, notify_lifecycle,
notify_problem, notify_problem,
notify_rdg_connection, notify_rdg_connection,
schedule_notify_auth_login,
schedule_notify_daily_report, schedule_notify_daily_report,
schedule_notify_lifecycle,
) )
router = APIRouter(prefix="/events", tags=["events"]) router = APIRouter(prefix="/events", tags=["events"])
@@ -100,15 +102,17 @@ def post_event(
problem = None problem = None
problem_created = False problem_created = False
deferred_daily_report_id: int | None = None deferred_daily_report_id: int | None = None
deferred_lifecycle_id: int | None = None
deferred_auth_login_id: int | None = None
if created: if created:
problem, problem_created = maybe_create_problem(db, event) problem, problem_created = maybe_create_problem(db, event)
maybe_auto_disconnect_stuck_rdp_session(db, event) maybe_auto_disconnect_stuck_rdp_session(db, event)
if event.type in DAILY_REPORT_EVENT_TYPES: if event.type in DAILY_REPORT_EVENT_TYPES:
deferred_daily_report_id = event.id deferred_daily_report_id = event.id
elif event.type == LIFECYCLE_EVENT_TYPE: elif event.type == LIFECYCLE_EVENT_TYPE:
notify_lifecycle(event, db=db) deferred_lifecycle_id = event.id
elif event.type in AUTH_LOGIN_SUCCESS_TYPES: elif event.type in AUTH_LOGIN_SUCCESS_TYPES:
notify_auth_login(event, db=db) deferred_auth_login_id = event.id
elif event.type in RDG_CONNECTION_TYPES: elif event.type in RDG_CONNECTION_TYPES:
notify_rdg_connection(event, db=db) notify_rdg_connection(event, db=db)
else: else:
@@ -122,6 +126,10 @@ def post_event(
if deferred_daily_report_id is not None: if deferred_daily_report_id is not None:
background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id) background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id)
if deferred_lifecycle_id is not None:
background_tasks.add_task(schedule_notify_lifecycle, deferred_lifecycle_id)
if deferred_auth_login_id is not None:
background_tasks.add_task(schedule_notify_auth_login, deferred_auth_login_id)
body = _ingest_response(event, created=created) body = _ingest_response(event, created=created)
if problem is not None: if problem is not None:
+1
View File
@@ -36,6 +36,7 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac" database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac"
sac_db_pool_size: int = 15 sac_db_pool_size: int = 15
sac_db_max_overflow: int = 25 sac_db_max_overflow: int = 25
sac_uvicorn_workers: int = 4
sac_public_url: str = "http://localhost:8000" sac_public_url: str = "http://localhost:8000"
# URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL. # URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL.
sac_agent_bundle_base_url: str = "" sac_agent_bundle_base_url: str = ""
+17 -3
View File
@@ -216,16 +216,30 @@ def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
def schedule_notify_daily_report(event_db_id: int) -> None: def schedule_notify_daily_report(event_db_id: int) -> None:
"""Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST).""" """Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST)."""
_schedule_deferred_event_notify(event_db_id, notify_daily_report, label="daily report")
def schedule_notify_lifecycle(event_db_id: int) -> None:
"""Отложенное lifecycle-оповещение (после commit ingest)."""
_schedule_deferred_event_notify(event_db_id, notify_lifecycle, label="lifecycle")
def schedule_notify_auth_login(event_db_id: int) -> None:
"""Отложенное оповещение об успешном RDP/SSH входе (после commit ingest)."""
_schedule_deferred_event_notify(event_db_id, notify_auth_login, label="auth login")
def _schedule_deferred_event_notify(event_db_id: int, handler, *, label: str) -> None:
from app.database import SessionLocal from app.database import SessionLocal
db = SessionLocal() db = SessionLocal()
try: try:
event = db.get(Event, event_db_id) event = db.get(Event, event_db_id)
if event is None: if event is None:
logger.warning("deferred daily report notify: event id=%s not found", event_db_id) logger.warning("deferred %s notify: event id=%s not found", label, event_db_id)
return return
notify_daily_report(event, db=db) handler(event, db=db)
except Exception: except Exception:
logger.exception("deferred daily report notify failed event_db_id=%s", event_db_id) logger.exception("deferred %s notify failed event_db_id=%s", label, event_db_id)
finally: finally:
db.close() db.close()
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.5.4" APP_VERSION = "0.5.5"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+45
View File
@@ -133,3 +133,48 @@ def test_ingest_daily_report_calls_notify_daily_report(client, auth_headers):
mock_daily.assert_called_once() mock_daily.assert_called_once()
mock_event.assert_not_called() mock_event.assert_not_called()
def test_ingest_lifecycle_defers_notify(client, auth_headers):
from unittest.mock import patch
from app.api.v1 import events as events_api
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"type": "agent.lifecycle",
"title": "started",
"summary": "agent started",
"details": {"lifecycle": "started"},
}
with patch.object(events_api, "schedule_notify_lifecycle") as mock_defer:
with patch.object(events_api, "notify_lifecycle") as mock_sync:
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
mock_defer.assert_called_once()
mock_sync.assert_not_called()
def test_ingest_auth_login_defers_notify(client, auth_headers):
from unittest.mock import patch
from app.api.v1 import events as events_api
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"category": "auth",
"type": "ssh.login.success",
"severity": "info",
"title": "SSH login",
"summary": "user@host",
}
with patch.object(events_api, "schedule_notify_auth_login") as mock_defer:
with patch.object(events_api, "notify_auth_login") as mock_sync:
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
mock_defer.assert_called_once()
mock_sync.assert_not_called()
+2
View File
@@ -7,6 +7,8 @@ DATABASE_URL=postgresql+psycopg2://sac:CHANGE_ME_POSTGRES_PASSWORD@127.0.0.1:543
# SQLAlchemy pool (uvicorn workers × concurrent ingest). Было по умолчанию 5+10 — мало для штурма 09:00. # SQLAlchemy pool (uvicorn workers × concurrent ingest). Было по умолчанию 5+10 — мало для штурма 09:00.
SAC_DB_POOL_SIZE=15 SAC_DB_POOL_SIZE=15
SAC_DB_MAX_OVERFLOW=25 SAC_DB_MAX_OVERFLOW=25
# Uvicorn workers (ingest burst). Читает deploy/systemd/sac-api-start.sh; при >1 отключите in-process host_silence scan ниже.
SAC_UVICORN_WORKERS=4
SAC_PUBLIC_URL=https://sac.kalinamall.ru SAC_PUBLIC_URL=https://sac.kalinamall.ru
# Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS). # Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS).
+13
View File
@@ -44,6 +44,19 @@ server {
proxy_read_timeout 960s; proxy_read_timeout 960s;
} }
# Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem.
location = /api/v1/events {
proxy_pass http://sac_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location / { location / {
proxy_pass http://sac_api; proxy_pass http://sac_api;
proxy_http_version 1.1; proxy_http_version 1.1;
+13
View File
@@ -69,6 +69,19 @@ server {
proxy_read_timeout 960s; proxy_read_timeout 960s;
} }
# Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem.
location = /api/v1/events {
proxy_pass http://sac_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location / { location / {
proxy_pass http://sac_api; proxy_pass http://sac_api;
proxy_http_version 1.1; proxy_http_version 1.1;
+4
View File
@@ -93,6 +93,10 @@ if [ -f "${APP_ROOT}/deploy/systemd/${SERVICE_NAME}.service" ]; then
systemctl daemon-reload systemctl daemon-reload
fi fi
fi fi
START_SH="${APP_ROOT}/deploy/systemd/sac-api-start.sh"
if [ -f "${START_SH}" ]; then
chmod 755 "${START_SH}"
fi
log "Проверка DATABASE_URL (как у uvicorn через SAC_CONFIG_FILE)" log "Проверка DATABASE_URL (как у uvicorn через SAC_CONFIG_FILE)"
sudo -u "${APP_USER}" bash -c " sudo -u "${APP_USER}" bash -c "
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Uvicorn launcher: SAC_UVICORN_WORKERS из sac-api.env (без systemd EnvironmentFile).
set -euo pipefail
APP_ROOT="${SAC_APP_ROOT:-/opt/security-alert-center}"
CONFIG_FILE="${SAC_CONFIG_FILE:-${APP_ROOT}/config/sac-api.env}"
VENV="${APP_ROOT}/backend/.venv"
HOST="127.0.0.1"
PORT="8000"
WORKERS=4
_read_env_int() {
local key="$1" default="$2" line val
[ -f "$CONFIG_FILE" ] || {
printf '%s\n' "$default"
return 0
}
line="$(grep -E "^[[:space:]]*${key}=" "$CONFIG_FILE" 2>/dev/null | tail -1)" || {
printf '%s\n' "$default"
return 0
}
val="${line#*=}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
val="${val#\"}"
val="${val%\"}"
val="${val#\'}"
val="${val%\'}"
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
printf '%s\n' "$val"
else
printf '%s\n' "$default"
fi
}
WORKERS="$(_read_env_int SAC_UVICORN_WORKERS 4)"
exec "${VENV}/bin/uvicorn" app.main:app \
--host "$HOST" \
--port "$PORT" \
--workers "$WORKERS" \
--timeout-graceful-shutdown 30
+1 -1
View File
@@ -13,7 +13,7 @@ WorkingDirectory=/opt/security-alert-center/backend
# Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д. # Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д.
Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env
Environment=PYTHONPATH=/opt/security-alert-center/backend Environment=PYTHONPATH=/opt/security-alert-center/backend
ExecStart=/opt/security-alert-center/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2 --timeout-graceful-shutdown 30 ExecStart=/opt/security-alert-center/deploy/systemd/sac-api-start.sh
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
TimeoutStopSec=20 TimeoutStopSec=20
+1 -1
View File
@@ -115,7 +115,7 @@ sudo /opt/sac-deploy.sh
Краткий **502 Bad Gateway** в UI (~10 с) возможен при перезапуске `sac-api` — список хостов автоматически повторяет запрос; после деплоя обновите страницу. Краткий **502 Bad Gateway** в UI (~10 с) возможен при перезапуске `sac-api` — список хостов автоматически повторяет запрос; после деплоя обновите страницу.
**Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **2 worker** uvicorn (`deploy/systemd/sac-api.service`). **Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **4 worker** uvicorn по умолчанию (`SAC_UVICORN_WORKERS`, `deploy/systemd/sac-api-start.sh`).
Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh` Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh`
+4 -4
View File
@@ -20,11 +20,11 @@
## SAC (backend / deploy) ## SAC (backend / deploy)
- [ ] **Defer** `notify_lifecycle` и `notify_auth_login` в background (как `report.daily.*``schedule_notify_daily_report`), чтобы ingest не ждал Telegram API. - [x] **Defer** `notify_lifecycle` и `notify_auth_login` в background (как `report.daily.*``schedule_notify_daily_report`), чтобы ingest не ждал Telegram API**0.5.5**
- [ ] **Uvicorn workers:** 4 по умолчанию или `SAC_UVICORN_WORKERS` в `sac-api.env` / unit. - [x] **Uvicorn workers:** 4 по умолчанию или `SAC_UVICORN_WORKERS` в `sac-api.env` / `sac-api-start.sh`**0.5.5**
- [ ] **nginx:** отдельный `location` для `POST /api/v1/events` с увеличенным `proxy_read_timeout` (сейчас общий `location /` — 60s). - [x] **nginx:** отдельный `location` для `POST /api/v1/events` с увеличенным `proxy_read_timeout` **0.5.5**
- [ ] Опционально: метрики/лог длительности ingest и очереди при burst. - [ ] Опционально: метрики/лог длительности ingest и очереди при burst.
- [ ] UI: предупреждение при массовом update («N хостов — рекомендуется пачками»). - [x] UI: предупреждение при массовом update («N хостов — рекомендуется пачками»)**0.5.5**
--- ---
+35
View File
@@ -138,6 +138,7 @@ import {
remoteActionTitleForHost, remoteActionTitleForHost,
} from "../utils/hostAgentUpgrade"; } from "../utils/hostAgentUpgrade";
import { import {
hostRemoteActionLogs,
isHostRemoteActionActive, isHostRemoteActionActive,
pollHostRemoteAction, pollHostRemoteAction,
runHostRemoteAction, runHostRemoteAction,
@@ -244,6 +245,37 @@ function isVersionOutdated(h: HostSummary): boolean {
return isAgentVersionOutdated(h.product_version, reference); return isAgentVersionOutdated(h.product_version, reference);
} }
function countOutdatedUpgradeableHosts(): number {
return (data.value?.items ?? []).filter(
(h) => isGitAgentUpgradeAvailable(h, data.value) && isVersionOutdated(h),
).length;
}
function countActiveRemoteUpgrades(): number {
return hostRemoteActionLogs.filter((e) => e.loading).length;
}
function confirmMassUpgradeIfNeeded(hostname: string): boolean {
const active = countActiveRemoteUpgrades();
const outdated = countOutdatedUpgradeableHosts();
if (active >= 2) {
return window.confirm(
`Сейчас обновляется ${active} хост(ов). Рекомендуется пачками по 2–3. Продолжить обновление «${hostname}»?`,
);
}
if (outdated > 3 && active >= 1) {
return window.confirm(
`Устарело агентов на странице: ${outdated}, уже идёт обновление. Продолжить «${hostname}»?`,
);
}
if (outdated > 3) {
return window.confirm(
`Устарело агентов на странице: ${outdated}. Рекомендуется обновлять пачками по 2–3, не все сразу. Продолжить «${hostname}»?`,
);
}
return true;
}
async function startAgentUpgrade(h: HostSummary) { async function startAgentUpgrade(h: HostSummary) {
if (!isGitAgentUpgradeAvailable(h, data.value)) { if (!isGitAgentUpgradeAvailable(h, data.value)) {
return; return;
@@ -252,6 +284,9 @@ async function startAgentUpgrade(h: HostSummary) {
showHostRemoteActionLog(h.id); showHostRemoteActionLog(h.id);
return; return;
} }
if (!confirmMassUpgradeIfNeeded(h.hostname)) {
return;
}
const kind = remoteActionKindForHost(h); const kind = remoteActionKindForHost(h);
if (!kind) return; if (!kind) return;
void runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind); void runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind);